feat(scaffolder): add fs:delete custom step

Signed-off-by: Andrea Falzetti <afalzetti@gmail.com>
This commit is contained in:
Andrea Falzetti
2021-07-02 17:16:39 +01:00
parent 375f5c3f01
commit 62c2f10f7d
8 changed files with 507 additions and 0 deletions
@@ -24,6 +24,10 @@ import {
} from './catalog';
import { createDebugLogAction } from './debug';
import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch';
import {
createFilesystemDeleteAction,
createFilesystemRenameAction,
} from './filesystem';
import {
createPublishAzureAction,
createPublishBitbucketAction,
@@ -68,5 +72,7 @@ export const createBuiltinActions = (options: {
createDebugLogAction(),
createCatalogRegisterAction({ catalogClient, integrations }),
createCatalogWriteAction(),
createFilesystemDeleteAction(),
createFilesystemRenameAction(),
];
};
@@ -0,0 +1,120 @@
/*
* 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 * as os from 'os';
import mockFs from 'mock-fs';
import { resolve as resolvePath } from 'path';
import { createFilesystemDeleteAction } from './delete';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import fs from 'fs-extra';
const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const workspacePath = resolvePath(root, 'my-workspace');
describe('fs:delete', () => {
const action = createFilesystemDeleteAction();
const mockContext = {
input: {
files: ['unit-test-a.js', 'unit-test-b.js'],
},
workspacePath,
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.restoreAllMocks();
mockFs({
[workspacePath]: {
'unit-test-a.js': 'hello',
'unit-test-b.js': 'world',
},
});
});
afterEach(() => {
mockFs.restore();
});
it('should throw an error when files is not an array', async () => {
await expect(
action.handler({
...mockContext,
input: { files: undefined },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: {} },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: '' },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: null },
}),
).rejects.toThrow(/files must be an Array/);
});
it('should throw when file name is not relative to the workspace', async () => {
await expect(
action.handler({
...mockContext,
input: { files: ['/foo/../../../index.js'] },
}),
).rejects.toThrow(/Relative path is not allowed to refer to a directory outside its parent/);
await expect(
action.handler({
...mockContext,
input: { files: ['../../../index.js'] },
}),
).rejects.toThrow(/Relative path is not allowed to refer to a directory outside its parent/);
});
it('should call fs.rm with the correct values', async () => {
const files = ['unit-test-a.js', 'unit-test-b.js'];
files.forEach(file => {
const filePath = resolvePath(workspacePath, file);
const fileExists = fs.existsSync(filePath);
expect(fileExists).toBe(true);
});
await action.handler(mockContext);
files.forEach(file => {
const filePath = resolvePath(workspacePath, file);
const fileExists = fs.existsSync(filePath);
expect(fileExists).toBe(false);
});
});
});
@@ -0,0 +1,58 @@
/*
* 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 { createTemplateAction } from '../../createTemplateAction';
import { InputError } from '@backstage/errors';
import { resolveSafeChildPath } from '@backstage/backend-common';
import fs from 'fs-extra';
export const createFilesystemDeleteAction = () => {
return createTemplateAction<{ files: string[] }>({
id: 'fs:delete',
schema: {
input: {
required: ['files'],
type: 'object',
properties: {
files: {
title: 'Files',
description: 'A list of files or directories that will be deleted',
type: 'array',
items: {
type: 'string',
},
},
},
},
},
async handler(ctx) {
if (!Array.isArray(ctx.input?.files)) {
throw new InputError('files must be an Array');
}
for (const file of ctx.input.files) {
const filepath = resolveSafeChildPath(ctx.workspacePath, file);
try {
await fs.remove(filepath);
ctx.logger.info(`File ${filepath} deleted successfully`);
} catch (err) {
ctx.logger.error(`Failed to delete file ${filepath}:`, err);
throw err;
}
}
},
});
};
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { createFilesystemDeleteAction } from './delete';
export { createFilesystemRenameAction } from './rename';
@@ -0,0 +1,206 @@
/*
* 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 * as os from 'os';
import mockFs from 'mock-fs';
import { resolve as resolvePath } from 'path';
import { createFilesystemRenameAction } from './rename';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import fs from 'fs-extra';
const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const workspacePath = resolvePath(root, 'my-workspace');
describe('fs:rename', () => {
const action = createFilesystemRenameAction();
const mockInputFiles = [
{
from: 'unit-test-a.js',
to: 'new-a.js',
},
{
from: 'unit-test-b.js',
to: 'new-b.js',
},
];
const mockContext = {
input: {
files: mockInputFiles,
},
workspacePath,
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.restoreAllMocks();
mockFs({
[workspacePath]: {
'unit-test-a.js': 'hello',
'unit-test-b.js': 'world',
'unit-test-c.js': 'i will be overwritten :-(',
},
});
});
afterEach(() => {
mockFs.restore();
});
it('should throw an error when files is not an array', async () => {
await expect(
action.handler({
...mockContext,
input: { files: undefined },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: {} },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: '' },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: null },
}),
).rejects.toThrow(/files must be an Array/);
});
it('should throw an error when files have missing from/to', async () => {
await expect(
action.handler({
...mockContext,
input: { files: ['old.md'] },
}),
).rejects.toThrow(/each file must have a from and to property/);
await expect(
action.handler({
...mockContext,
input: { files: [{ from: 'old.md' }] },
}),
).rejects.toThrow(/each file must have a from and to property/);
await expect(
action.handler({
...mockContext,
input: { files: [{ to: 'new.md' }] },
}),
).rejects.toThrow(/each file must have a from and to property/);
});
it('should throw when file name is not relative to the workspace', async () => {
await expect(
action.handler({
...mockContext,
input: { files: [{ from: 'index.js', to: '/core/../../../index.js' }] },
}),
).rejects.toThrow(/Relative path is not allowed to refer to a directory outside its parent/);
await expect(
action.handler({
...mockContext,
input: { files: [{ from: '/core/../../../index.js', to: 'index.js' }] },
}),
).rejects.toThrow(/Relative path is not allowed to refer to a directory outside its parent/);
});
it('should throw is trying to override by mistake', async () => {
const destFile = 'unit-test-c.js';
const filePath = resolvePath(workspacePath, destFile);
const beforeContent = fs.readFileSync(filePath, 'utf-8');
await expect(
action.handler({
...mockContext,
input: {
files: [
{
from: 'unit-test-a.js',
to: 'unit-test-c.js',
},
],
},
}),
).rejects.toThrow(/dest already exists/);
const afterContent = fs.readFileSync(filePath, 'utf-8');
expect(beforeContent).toEqual(afterContent);
});
it('should call fs.move with the correct values', async () => {
mockInputFiles.forEach(file => {
const filePath = resolvePath(workspacePath, file.from);
const fileExists = fs.existsSync(filePath);
expect(fileExists).toBe(true);
});
await action.handler(mockContext);
mockInputFiles.forEach(file => {
const filePath = resolvePath(workspacePath, file.from);
const fileExists = fs.existsSync(filePath);
expect(fileExists).toBe(false);
});
});
it('should override when requested', async () => {
const sourceFile = 'unit-test-a.js';
const destFile = 'unit-test-c.js';
const sourceFilePath = resolvePath(workspacePath, sourceFile);
const destFilePath = resolvePath(workspacePath, destFile);
const sourceBeforeContent = fs.readFileSync(sourceFilePath, 'utf-8');
const destBeforeContent = fs.readFileSync(destFilePath, 'utf-8');
expect(sourceBeforeContent).not.toEqual(destBeforeContent);
await action.handler({
...mockContext,
input: {
files: [
{
from: sourceFile,
to: destFile,
overwrite: true,
},
],
},
});
const destAfterContent = fs.readFileSync(destFilePath, 'utf-8');
expect(sourceBeforeContent).toEqual(destAfterContent);
});
});
@@ -0,0 +1,93 @@
/*
* 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 { createTemplateAction } from '../../createTemplateAction';
import { resolveSafeChildPath } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { JsonObject } from '@backstage/config';
import fs from 'fs-extra';
interface FilesToRename extends JsonObject {
from: string;
to: string;
}
export const createFilesystemRenameAction = () => {
return createTemplateAction<{ files: FilesToRename }>({
id: 'fs:rename',
schema: {
input: {
required: ['files'],
type: 'object',
properties: {
files: {
title: 'Files',
description: 'A list of file names that will be renamed',
type: 'array',
items: {
type: 'object',
required: ['from', 'to'],
properties: {
from: {
type: 'string',
title: 'The source location of the file to be renamed',
},
to: {
type: 'string',
title: 'The destination of the new file',
},
overwrite: {
type: 'boolean',
title:
'Overwrite existing file or directory, default is false',
},
},
},
},
},
},
},
async handler(ctx) {
if (!Array.isArray(ctx.input?.files)) {
throw new InputError('files must be an Array');
}
for (const file of ctx.input.files) {
if (!file.from || !file.to) {
throw new InputError('each file must have a from and to property');
}
const sourceFilepath = resolveSafeChildPath(ctx.workspacePath, file.from);
const destFilepath = resolveSafeChildPath(ctx.workspacePath, file.to);
try {
await fs.move(sourceFilepath, destFilepath, {
overwrite: file.overwrite || false,
});
ctx.logger.info(
`File ${sourceFilepath} renamed to ${destFilepath} successfully`,
);
} catch (err) {
ctx.logger.error(
`Failed to rename file ${sourceFilepath} to ${destFilepath}:`,
err,
);
throw err;
}
}
},
});
};
@@ -18,4 +18,5 @@ export * from './catalog';
export { createBuiltinActions } from './createBuiltinActions';
export * from './debug';
export * from './fetch';
export * from './filesystem';
export * from './publish';