Adds a number of examples for builtin functions

This patch also proposes:
- changing the pattern by which the examples are imported
- adds tests for the examples

Signed-off-by: Brian Fletcher <brian@roadie.io>
This commit is contained in:
Brian Fletcher
2023-07-28 11:05:15 +01:00
parent 32f539dce2
commit 0b1d775be0
28 changed files with 1210 additions and 154 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Adds examples to a few scaffolder actions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-node': patch
---
Export `TemplateExample` from the `createTemplateAction` type.
@@ -0,0 +1,78 @@
/*
* 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 { PassThrough } from 'stream';
import os from 'os';
import { getVoidLogger } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { createFetchCatalogEntityAction } from './fetch';
import { examples } from './fetch.examples';
import yaml from 'yaml';
describe('catalog:fetch examples', () => {
const getEntityByRef = jest.fn();
const getEntitiesByRefs = jest.fn();
const catalogClient = {
getEntityByRef: getEntityByRef,
getEntitiesByRefs: getEntitiesByRefs,
};
const action = createFetchCatalogEntityAction({
catalogClient: catalogClient as unknown as CatalogApi,
});
const mockContext = {
workspacePath: os.tmpdir(),
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
secrets: { backstageToken: 'secret' },
};
beforeEach(() => {
jest.resetAllMocks();
});
describe('fetch single entity', () => {
it('should return entity from catalog', async () => {
getEntityByRef.mockReturnValueOnce({
metadata: {
namespace: 'default',
name: 'name',
},
kind: 'Component',
} as Entity);
await action.handler({
...mockContext,
input: yaml.parse(examples[0].example).steps[0].input,
});
expect(getEntityByRef).toHaveBeenCalledWith('component:default/name', {
token: 'secret',
});
expect(mockContext.output).toHaveBeenCalledWith('entity', {
metadata: {
namespace: 'default',
name: 'name',
},
kind: 'Component',
});
});
});
});
@@ -0,0 +1,51 @@
/*
* 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 { TemplateExample } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description: 'Fetch entity by reference',
example: yaml.stringify({
steps: [
{
action: 'catalog:fetch',
id: 'fetch',
name: 'Fetch catalog entity',
input: {
entityRef: 'component:default/name',
},
},
],
}),
},
{
description: 'Fetch multiple entities by referencse',
example: yaml.stringify({
steps: [
{
action: 'catalog:fetch',
id: 'fetchMultiple',
name: 'Fetch catalog entities',
input: {
entityRefs: ['component:default/name'],
},
},
],
}),
},
];
@@ -16,45 +16,12 @@
import { CatalogApi } from '@backstage/catalog-client';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
import { z } from 'zod';
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
import { examples } from './fetch.examples';
const id = 'catalog:fetch';
const examples = [
{
description: 'Fetch entity by reference',
example: yaml.stringify({
steps: [
{
action: id,
id: 'fetch',
name: 'Fetch catalog entity',
input: {
entityRef: 'component:default/name',
},
},
],
}),
},
{
description: 'Fetch multiple entities by referencse',
example: yaml.stringify({
steps: [
{
action: id,
id: 'fetchMultiple',
name: 'Fetch catalog entities',
input: {
entityRefs: ['component:default/name'],
},
},
],
}),
},
];
/**
* Returns entity or entities from the catalog by entity reference(s).
*
@@ -0,0 +1,108 @@
/*
* 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 { PassThrough } from 'stream';
import os from 'os';
import { getVoidLogger } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { createCatalogRegisterAction } from './register';
import { Entity } from '@backstage/catalog-model';
import { examples } from './register.examples';
import yaml from 'yaml';
describe('catalog:register', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'token' }],
},
}),
);
const addLocation = jest.fn();
const catalogClient = {
addLocation: addLocation,
};
const action = createCatalogRegisterAction({
integrations,
catalogClient: catalogClient as unknown as CatalogApi,
});
const mockContext = {
workspacePath: os.tmpdir(),
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
});
it('should register location in catalog', async () => {
addLocation
.mockResolvedValueOnce({
entities: [],
})
.mockResolvedValueOnce({
entities: [
{
metadata: {
namespace: 'default',
name: 'test',
},
kind: 'Component',
} as Entity,
],
});
await action.handler({
...mockContext,
input: yaml.parse(examples[0].example).steps[0].input,
});
expect(addLocation).toHaveBeenNthCalledWith(
1,
{
type: 'url',
target:
'http://github.com/backstage/backstage/blob/master/catalog-info.yaml',
},
{},
);
expect(addLocation).toHaveBeenNthCalledWith(
2,
{
dryRun: true,
type: 'url',
target:
'http://github.com/backstage/backstage/blob/master/catalog-info.yaml',
},
{},
);
expect(mockContext.output).toHaveBeenCalledWith(
'entityRef',
'component:default/test',
);
expect(mockContext.output).toHaveBeenCalledWith(
'catalogInfoUrl',
'http://github.com/backstage/backstage/blob/master/catalog-info.yaml',
);
});
});
@@ -0,0 +1,37 @@
/*
* 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 { TemplateExample } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description: 'Register with the catalog',
example: yaml.stringify({
steps: [
{
action: 'catalog:register',
id: 'register-with-catalog',
name: 'Register with the catalog',
input: {
catalogInfoUrl:
'http://github.com/backstage/backstage/blob/master/catalog-info.yaml',
},
},
],
}),
},
];
@@ -19,29 +19,10 @@ import { ScmIntegrations } from '@backstage/integration';
import { CatalogApi } from '@backstage/catalog-client';
import { stringifyEntityRef, Entity } from '@backstage/catalog-model';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
import { examples } from './register.examples';
const id = 'catalog:register';
const examples = [
{
description: 'Register with the catalog',
example: yaml.stringify({
steps: [
{
action: id,
id: 'register-with-catalog',
name: 'Register with the catalog',
input: {
catalogInfoUrl:
'http://github.com/backstage/backstage/blob/master/catalog-info.yaml',
},
},
],
}),
},
];
/**
* Registers entities from a catalog descriptor file in the workspace into the software catalog.
* @public
@@ -0,0 +1,72 @@
/*
* 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 fs from 'fs-extra';
jest.mock('fs-extra');
const fsMock = fs as jest.Mocked<typeof fs>;
import { PassThrough } from 'stream';
import os from 'os';
import { getVoidLogger } from '@backstage/backend-common';
import { createCatalogWriteAction } from './write';
import { resolve as resolvePath } from 'path';
import * as yaml from 'yaml';
import { examples } from './write.examples';
describe('catalog:write', () => {
const action = createCatalogWriteAction();
const mockContext = {
workspacePath: os.tmpdir(),
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
});
it('should write the catalog-info.yml in the workspace', async () => {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {},
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'default/owner',
},
};
await action.handler({
...mockContext,
input: yaml.parse(examples[0].example).steps[0].input,
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(mockContext.workspacePath, 'catalog-info.yaml'),
yaml.stringify(entity),
);
});
});
@@ -0,0 +1,48 @@
/*
* 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 { TemplateExample } from '@backstage/plugin-scaffolder-node';
import * as yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description: 'Write a catalog yaml file',
example: yaml.stringify({
steps: [
{
action: 'catalog:write',
id: 'create-catalog-info-file',
name: 'Create catalog file',
input: {
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {},
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'default/owner',
},
},
},
},
],
}),
},
];
@@ -19,39 +19,10 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import * as yaml from 'yaml';
import { resolveSafeChildPath } from '@backstage/backend-common';
import { z } from 'zod';
import { examples } from './write.examples';
const id = 'catalog:write';
const examples = [
{
description: 'Write a catalog yaml file',
example: yaml.stringify({
steps: [
{
action: id,
id: 'create-catalog-info-file',
name: 'Create catalog file',
input: {
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {},
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'default/owner',
},
},
},
},
],
}),
},
];
/**
* Writes a catalog descriptor file containing the provided entity to a path in the workspace.
* @public
@@ -0,0 +1,86 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import mockFs from 'mock-fs';
import os from 'os';
import { Writable } from 'stream';
import { createDebugLogAction } from './log';
import { join } from 'path';
import yaml from 'yaml';
import { examples } from './log.examples';
describe('debug:log examples', () => {
const logStream = {
write: jest.fn(),
} as jest.Mocked<Partial<Writable>> as jest.Mocked<Writable>;
const mockTmpDir = os.tmpdir();
const mockContext = {
input: {},
baseUrl: 'somebase',
workspacePath: mockTmpDir,
logger: getVoidLogger(),
logStream,
output: jest.fn(),
createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir),
};
const action = createDebugLogAction();
beforeEach(() => {
mockFs({
[`${mockContext.workspacePath}/README.md`]: '',
[`${mockContext.workspacePath}/a-directory/index.md`]: '',
});
jest.resetAllMocks();
});
afterEach(() => {
mockFs.restore();
});
it('should log message', async () => {
const context = {
...mockContext,
input: yaml.parse(examples[0].example).steps[0].input,
};
await action.handler(context);
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect.stringContaining('Hello Backstage!'),
);
});
it('should log the workspace content, if active', async () => {
const context = {
...mockContext,
input: yaml.parse(examples[1].example).steps[0].input,
};
await action.handler(context);
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect.stringContaining('README.md'),
);
expect(logStream.write).toHaveBeenCalledWith(
expect.stringContaining(join('a-directory', 'index.md')),
);
});
});
@@ -0,0 +1,51 @@
/*
* 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 { TemplateExample } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description: 'Write a debug message',
example: yaml.stringify({
steps: [
{
action: 'debug:log',
id: 'write-debug-line',
name: 'Write "Hello Backstage!" log line',
input: {
message: 'Hello Backstage!',
},
},
],
}),
},
{
description: 'List the workspace directory',
example: yaml.stringify({
steps: [
{
action: 'debug:log',
id: 'write-workspace-directory',
name: 'List the workspace directory',
input: {
listWorkspace: true,
},
},
],
}),
},
];
@@ -17,43 +17,10 @@
import { readdir, stat } from 'fs-extra';
import { relative, join } from 'path';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
import { examples } from './log.examples';
const id = 'debug:log';
const examples = [
{
description: 'Write a debug message',
example: yaml.stringify({
steps: [
{
action: id,
id: 'write-debug-line',
name: 'Write "Hello Backstage!" log line',
input: {
message: 'Hello Backstage!',
},
},
],
}),
},
{
description: 'List the workspace directory',
example: yaml.stringify({
steps: [
{
action: id,
id: 'write-workspace-directory',
name: 'List the workspace directory',
input: {
listWorkspace: true,
},
},
],
}),
},
];
/**
* Writes a message into the log or lists all files in the workspace
*
@@ -0,0 +1,61 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import mockFs from 'mock-fs';
import { createWaitAction } from './wait';
import { Writable } from 'stream';
import os from 'os';
import { examples } from './wait.examples';
import yaml from 'yaml';
describe('debug:wait examples', () => {
const action = createWaitAction();
const logStream = {
write: jest.fn(),
} as jest.Mocked<Partial<Writable>> as jest.Mocked<Writable>;
const mockTmpDir = os.tmpdir();
const mockContext = {
input: {},
baseUrl: 'somebase',
workspacePath: mockTmpDir,
logger: getVoidLogger(),
logStream,
output: jest.fn(),
createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir),
};
beforeEach(() => {
jest.resetAllMocks();
});
afterEach(() => {
mockFs.restore();
});
it('should wait for specified period of seconds', async () => {
const context = {
...mockContext,
input: yaml.parse(examples[0].example).steps[0].input,
};
const start = new Date().getTime();
await action.handler(context);
const end = new Date().getTime();
expect(end - start).toBeGreaterThanOrEqual(50);
});
});
@@ -0,0 +1,66 @@
/*
* 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 { TemplateExample } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description: 'Waiting for 50 milliseconds',
example: yaml.stringify({
steps: [
{
action: 'debug:wait',
id: 'wait-milliseconds',
name: 'Waiting for 50 milliseconds',
input: {
milliseconds: 50,
},
},
],
}),
},
{
description: 'Waiting for 5 seconds',
example: yaml.stringify({
steps: [
{
action: 'debug:wait',
id: 'wait-5sec',
name: 'Waiting for 5 seconds',
input: {
seconds: 5,
},
},
],
}),
},
{
description: 'Waiting for 1 minutes',
example: yaml.stringify({
steps: [
{
action: 'debug:wait',
id: 'wait-1min',
name: 'Waiting for 1 minutes',
input: {
minutes: 1,
},
},
],
}),
},
];
@@ -16,46 +16,13 @@
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { HumanDuration } from '@backstage/types';
import yaml from 'yaml';
import { Duration } from 'luxon';
import { examples } from './wait.examples';
const id = 'debug:wait';
const MAX_WAIT_TIME_IN_ISO = 'T00:00:30';
const examples = [
{
description: 'Waiting for 5 seconds',
example: yaml.stringify({
steps: [
{
action: id,
id: 'wait-5sec',
name: 'Waiting for 5 seconds',
input: {
seconds: 5,
},
},
],
}),
},
{
description: 'Waiting for 5 minutes',
example: yaml.stringify({
steps: [
{
action: id,
id: 'wait-5min',
name: 'Waiting for 5 minutes',
input: {
minutes: 5,
},
},
],
}),
},
];
/**
* Waits for a certain period of time.
*
@@ -0,0 +1,85 @@
/*
* 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 yaml from 'yaml';
jest.mock('./helpers');
import os from 'os';
import { resolve as resolvePath } from 'path';
import { getVoidLogger, UrlReader } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { createFetchPlainAction } from './plain';
import { PassThrough } from 'stream';
import { fetchContents } from './helpers';
import { examples } from './plain.examples';
describe('fetch:plain examples', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'token' }],
},
}),
);
const reader: UrlReader = {
readUrl: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
});
const action = createFetchPlainAction({ integrations, reader });
const mockContext = {
workspacePath: os.tmpdir(),
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
it('should fetch plain', async () => {
await action.handler({
...mockContext,
input: yaml.parse(examples[0].example).steps[0].input,
});
expect(fetchContents).toHaveBeenCalledWith(
expect.objectContaining({
outputPath: resolvePath(mockContext.workspacePath),
fetchUrl:
'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets',
}),
);
});
it('should fetch plain to a specified directory', async () => {
await action.handler({
...mockContext,
input: yaml.parse(examples[1].example).steps[0].input,
});
expect(fetchContents).toHaveBeenCalledWith(
expect.objectContaining({
outputPath: resolvePath(mockContext.workspacePath, 'fetched-data'),
fetchUrl:
'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets',
}),
);
});
});
@@ -0,0 +1,53 @@
/*
* 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 { TemplateExample } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description: 'Downloads content and places it in the workspace.',
example: yaml.stringify({
steps: [
{
action: 'fetch:plain',
id: 'fetch-plain',
name: 'Fetch plain',
input: {
url: 'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets',
},
},
],
}),
},
{
description:
'Optionally, if you would prefer the data to be downloaded to a subdirectory in the workspace you may specify the targetPath input option.',
example: yaml.stringify({
steps: [
{
action: 'fetch:plain',
id: 'fetch-plain',
name: 'Fetch plain',
input: {
url: 'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets',
targetPath: 'fetched-data',
},
},
],
}),
},
];
@@ -18,6 +18,9 @@ import { UrlReader, resolveSafeChildPath } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { fetchContents } from './helpers';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { examples } from './plain.examples';
export const ACTION_ID = 'fetch:plain';
/**
* Downloads content and places it in the workspace, or optionally
@@ -31,7 +34,8 @@ export function createFetchPlainAction(options: {
const { reader, integrations } = options;
return createTemplateAction<{ url: string; targetPath?: string }>({
id: 'fetch:plain',
id: ACTION_ID,
examples,
description:
'Downloads content and places it in the workspace, or optionally in a subdirectory specified by the `targetPath` input option.',
schema: {
@@ -0,0 +1,71 @@
/*
* 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 yaml from 'yaml';
jest.mock('./helpers');
import os from 'os';
import { resolve as resolvePath } from 'path';
import { getVoidLogger, UrlReader } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { createFetchPlainFileAction } from './plainFile';
import { PassThrough } from 'stream';
import { fetchFile } from './helpers';
import { examples } from './plainFile.examples';
describe('fetch:plain:file examples', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'token' }],
},
}),
);
const reader: UrlReader = {
readUrl: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
});
const action = createFetchPlainFileAction({ integrations, reader });
const mockContext = {
workspacePath: os.tmpdir(),
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
it('should fetch plain', async () => {
await action.handler({
...mockContext,
input: yaml.parse(examples[0].example).steps[0].input,
});
expect(fetchFile).toHaveBeenCalledWith(
expect.objectContaining({
outputPath: resolvePath(mockContext.workspacePath, 'target-path'),
fetchUrl:
'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets/Backstage%20Community%20Sessions.png',
}),
);
});
});
@@ -0,0 +1,37 @@
/*
* 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 { TemplateExample } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description: 'Downloads a file and places it in the workspace.',
example: yaml.stringify({
steps: [
{
action: 'fetch:plain:file',
id: 'fetch-plain-file',
name: 'Fetch plain file',
input: {
url: 'https://github.com/backstage/community/tree/main/backstage-community-sessions/assets/Backstage%20Community%20Sessions.png',
targetPath: 'target-path',
},
},
],
}),
},
];
@@ -18,6 +18,7 @@ import { UrlReader, resolveSafeChildPath } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { fetchFile } from './helpers';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { examples } from './plainFile.examples';
/**
* Downloads content and places it in the workspace, or optionally
@@ -33,6 +34,7 @@ export function createFetchPlainFileAction(options: {
return createTemplateAction<{ url: string; targetPath: string }>({
id: 'fetch:plain:file',
description: 'Downloads single file and places it in the workspace.',
examples,
schema: {
input: {
type: 'object',
@@ -0,0 +1,230 @@
/*
* 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 os from 'os';
import { join as joinPath, sep as pathSep } from 'path';
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import {
getVoidLogger,
resolvePackagePath,
UrlReader,
} from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { PassThrough } from 'stream';
import { fetchContents } from './helpers';
import { createFetchTemplateAction } from './template';
import {
ActionContext,
TemplateAction,
} from '@backstage/plugin-scaffolder-node';
import { examples } from './template.examples';
import yaml from 'yaml';
jest.mock('./helpers', () => ({
fetchContents: jest.fn(),
}));
type FetchTemplateInput = ReturnType<
typeof createFetchTemplateAction
> extends TemplateAction<infer U>
? U
: never;
const realFiles = Object.fromEntries(
[
resolvePackagePath(
'@backstage/plugin-scaffolder-backend',
'assets',
'nunjucks.js.txt',
),
].map(k => [k, mockFs.load(k)]),
);
const aBinaryFile = fs.readFileSync(
resolvePackagePath(
'@backstage/plugin-scaffolder-backend',
'fixtures/test-nested-template/public/react-logo192.png',
),
);
const mockFetchContents = fetchContents as jest.MockedFunction<
typeof fetchContents
>;
describe('fetch:template examples', () => {
let action: TemplateAction<any>;
const workspacePath = os.tmpdir();
const createTemporaryDirectory: jest.MockedFunction<
ActionContext<FetchTemplateInput>['createTemporaryDirectory']
> = jest.fn(() =>
Promise.resolve(
joinPath(workspacePath, `${createTemporaryDirectory.mock.calls.length}`),
),
);
const logger = getVoidLogger();
const mockContext = (input: any) => ({
templateInfo: {
baseUrl: 'base-url',
entityRef: 'template:default/test-template',
},
input: input,
output: jest.fn(),
logStream: new PassThrough(),
logger,
workspacePath,
createTemporaryDirectory,
});
beforeEach(() => {
mockFs({
...realFiles,
});
action = createFetchTemplateAction({
reader: Symbol('UrlReader') as unknown as UrlReader,
integrations: Symbol('Integrations') as unknown as ScmIntegrations,
});
});
afterEach(() => {
mockFs.restore();
});
describe('handler', () => {
describe('with valid input', () => {
let context: ActionContext<FetchTemplateInput>;
beforeEach(async () => {
context = mockContext(yaml.parse(examples[0].example).steps[0].input);
mockFetchContents.mockImplementation(({ outputPath }) => {
mockFs({
...realFiles,
[outputPath]: {
'an-executable.sh': mockFs.file({
content: '#!/usr/bin/env bash',
mode: parseInt('100755', 8),
}),
'empty-dir-${{ values.count }}': {},
'static.txt': 'static content',
'${{ values.name }}.txt': 'static content',
subdir: {
'templated-content.txt':
'${{ values.name }}: ${{ values.count }}',
},
'.${{ values.name }}': '${{ values.itemList | dump }}',
'a-binary-file.png': aBinaryFile,
symlink: mockFs.symlink({
path: 'a-binary-file.png',
}),
brokenSymlink: mockFs.symlink({
path: './not-a-real-file.txt',
}),
},
});
return Promise.resolve();
});
await action.handler(context);
});
it('uses fetchContents to retrieve the template content', () => {
expect(mockFetchContents).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: context.templateInfo?.baseUrl,
fetchUrl: context.input.url,
}),
);
});
it('copies files with no templating in names or content successfully', async () => {
await expect(
fs.readFile(`${workspacePath}/target/static.txt`, 'utf-8'),
).resolves.toEqual('static content');
});
it('copies files with templated names successfully', async () => {
await expect(
fs.readFile(`${workspacePath}/target/test-project.txt`, 'utf-8'),
).resolves.toEqual('static content');
});
it('copies files with templated content successfully', async () => {
await expect(
fs.readFile(
`${workspacePath}/target/subdir/templated-content.txt`,
'utf-8',
),
).resolves.toEqual('test-project: 1234');
});
it('processes dotfiles', async () => {
await expect(
fs.readFile(`${workspacePath}/target/.test-project`, 'utf-8'),
).resolves.toEqual('["first","second","third"]');
});
it('copies empty directories', async () => {
await expect(
fs.readdir(`${workspacePath}/target/empty-dir-1234`, 'utf-8'),
).resolves.toEqual([]);
});
it('copies binary files as-is without processing them', async () => {
await expect(
fs.readFile(`${workspacePath}/target/a-binary-file.png`),
).resolves.toEqual(aBinaryFile);
});
it('copies files and maintains the original file permissions', async () => {
await expect(
fs
.stat(`${workspacePath}/target/an-executable.sh`)
.then(fObj => fObj.mode),
).resolves.toEqual(parseInt('100755', 8));
});
it('copies file symlinks as-is without processing them', async () => {
await expect(
fs
.lstat(`${workspacePath}/target/symlink`)
.then(i => i.isSymbolicLink()),
).resolves.toBe(true);
await expect(
fs.realpath(`${workspacePath}/target/symlink`),
).resolves.toBe(joinPath(workspacePath, 'target', 'a-binary-file.png'));
});
it('copies broken symlinks as-is without processing them', async () => {
await expect(
fs
.lstat(`${workspacePath}/target/brokenSymlink`)
.then(i => i.isSymbolicLink()),
).resolves.toBe(true);
await expect(
fs.readlink(`${workspacePath}/target/brokenSymlink`),
).resolves.toEqual(`.${pathSep}not-a-real-file.txt`);
});
});
});
});
@@ -0,0 +1,44 @@
/*
* 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 { TemplateExample } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description:
'Downloads a skelaton directory that lives alongside the template file and fill it out with values.',
example: yaml.stringify({
steps: [
{
action: 'fetch:template',
id: 'fetch-template',
name: 'Fetch template',
input: {
url: './skeleton',
targetPath: './target',
values: {
name: 'test-project',
count: 1234,
itemList: ['first', 'second', 'third'],
showDummyFile: false,
},
},
},
],
}),
},
];
@@ -29,6 +29,7 @@ import {
TemplateGlobal,
} from '../../../../lib/templating/SecureTemplater';
import { createDefaultFilters } from '../../../../lib/templating/filters';
import { examples } from './template.examples';
/**
* Downloads a skeleton, templates variables into file and directory names and content.
@@ -70,6 +71,7 @@ export function createFetchTemplateAction(options: {
id: 'fetch:template',
description:
'Downloads a skeleton, templates variables into file and directory names and content, and places the result in the workspace, or optionally in a subdirectory specified by the `targetPath` input option.',
examples,
schema: {
input: {
type: 'object',
@@ -20,6 +20,12 @@ import { Schema } from 'jsonschema';
import zodToJsonSchema from 'zod-to-json-schema';
import { JsonObject } from '@backstage/types';
/** @public */
export type TemplateExample = {
description: string;
example: string;
};
/** @public */
export type TemplateActionOptions<
TActionInput extends JsonObject = {},
@@ -29,7 +35,7 @@ export type TemplateActionOptions<
> = {
id: string;
description?: string;
examples?: { description: string; example: string }[];
examples?: TemplateExample[];
supportsDryRun?: boolean;
schema?: {
input?: TInputSchema;
@@ -17,5 +17,6 @@
export {
createTemplateAction,
type TemplateActionOptions,
type TemplateExample,
} from './createTemplateAction';
export { type ActionContext, type TemplateAction } from './types';