breaking: removing alphav1 support for templates

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2021-06-19 02:17:01 +02:00
parent ef48b9e4b7
commit 6a09ed555d
65 changed files with 194 additions and 5185 deletions
@@ -265,6 +265,7 @@ export class LocationReaders implements LocationReader {
)}, ${e}`;
emit(result.inputError(item.location, message));
logger.warn(message);
logger.debug(e.stack);
return undefined;
}
}
@@ -21,7 +21,7 @@ import {
GroupEntity,
ResourceEntity,
SystemEntity,
TemplateEntity,
TemplateEntityV1beta2,
UserEntity,
} from '@backstage/catalog-model';
import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
@@ -522,22 +522,13 @@ describe('BuiltinKindsEntityProcessor', () => {
});
});
it('generates relations for template entities', async () => {
const entity: TemplateEntity = {
apiVersion: 'backstage.io/v1alpha1',
const entity: TemplateEntityV1beta2 = {
apiVersion: 'backstage.io/v1beta2',
kind: 'Template',
metadata: { name: 'n' },
spec: {
schema: {
properties: {
description: {
title: 'd',
type: 'string',
description: 'des',
},
},
},
templater: 'cookiecutter',
path: '.',
parameters: {},
steps: [],
type: 'service',
owner: 'o',
},
@@ -46,8 +46,7 @@ import {
resourceEntityV1alpha1Validator,
SystemEntity,
systemEntityV1alpha1Validator,
TemplateEntity,
templateEntityV1alpha1Validator,
TemplateEntityV1beta2,
templateEntityV1beta2Validator,
UserEntity,
userEntityV1alpha1Validator,
@@ -62,7 +61,6 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
resourceEntityV1alpha1Validator,
groupEntityV1alpha1Validator,
locationEntityV1alpha1Validator,
templateEntityV1alpha1Validator,
templateEntityV1beta2Validator,
userEntityV1alpha1Validator,
systemEntityV1alpha1Validator,
@@ -136,7 +134,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
* Emit relations for the Template kind
*/
if (entity.kind === 'Template') {
const template = entity as TemplateEntity;
const template = entity as TemplateEntityV1beta2;
doEmit(
template.spec.owner,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
@@ -124,6 +124,7 @@ export class DefaultCatalogProcessingOrchestrator
};
} catch (error) {
this.options.logger.warn(error.message);
this.options.logger.debug(error.stack);
return {
ok: false,
errors: collector.results().errors.concat(error),
@@ -6,4 +6,4 @@ metadata:
spec:
type: url
targets:
- https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml
# - https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml
@@ -14,10 +14,7 @@
* limitations under the License.
*/
import {
TemplateEntityV1alpha1,
TemplateEntityV1beta2,
} from '@backstage/catalog-model';
import { TemplateEntityV1beta2 } from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/catalog-client';
import { ConflictError, NotFoundError } from '@backstage/errors';
@@ -35,7 +32,7 @@ export class CatalogEntityClient {
async findTemplate(
templateName: string,
options?: { token?: string },
): Promise<TemplateEntityV1alpha1 | TemplateEntityV1beta2> {
): Promise<TemplateEntityV1beta2> {
const { items: templates } = (await this.catalogClient.getEntities(
{
filter: {
@@ -44,7 +41,7 @@ export class CatalogEntityClient {
},
},
options,
)) as { items: (TemplateEntityV1alpha1 | TemplateEntityV1beta2)[] };
)) as { items: TemplateEntityV1beta2[] };
if (templates.length !== 1) {
if (templates.length > 1) {
@@ -14,15 +14,15 @@
* limitations under the License.
*/
import { UrlReader } from '@backstage/backend-common';
import { ContainerRunner, UrlReader } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { ScmIntegrations } from '@backstage/integration';
import { Config } from '@backstage/config';
import { TemplaterBuilder } from '../../stages';
import {
createCatalogRegisterAction,
createCatalogWriteAction,
createCatalogRegisterAction,
} from './catalog';
import { createDebugLogAction } from './debug';
import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch';
import {
@@ -41,10 +41,16 @@ export const createBuiltinActions = (options: {
reader: UrlReader;
integrations: ScmIntegrations;
catalogClient: CatalogApi;
templaters: TemplaterBuilder;
containerRunner: ContainerRunner;
config: Config;
}) => {
const { reader, integrations, templaters, catalogClient, config } = options;
const {
reader,
integrations,
containerRunner,
catalogClient,
config,
} = options;
return [
createFetchPlainAction({
@@ -54,7 +60,7 @@ export const createBuiltinActions = (options: {
createFetchCookiecutterAction({
reader,
integrations,
templaters,
containerRunner,
}),
createPublishGithubAction({
integrations,
@@ -14,22 +14,116 @@
* limitations under the License.
*/
import { UrlReader, resolveSafeChildPath } from '@backstage/backend-common';
import { JsonObject } from '@backstage/config';
import {
ContainerRunner,
UrlReader,
resolveSafeChildPath,
} from '@backstage/backend-common';
import { JsonObject, JsonValue } from '@backstage/config';
import { InputError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import commandExists from 'command-exists';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { TemplaterBuilder, TemplaterValues } from '../../../stages/templater';
import path, { resolve as resolvePath } from 'path';
import { Writable } from 'stream';
import { runCommand } from '../helpers';
import { createTemplateAction } from '../../createTemplateAction';
import { fetchContents } from './helpers';
export class CookiecutterRunner {
private readonly containerRunner: ContainerRunner;
constructor({ containerRunner }: { containerRunner: ContainerRunner }) {
this.containerRunner = containerRunner;
}
private async fetchTemplateCookieCutter(
directory: string,
): Promise<Record<string, JsonValue>> {
try {
return await fs.readJSON(path.join(directory, 'cookiecutter.json'));
} catch (ex) {
if (ex.code !== 'ENOENT') {
throw ex;
}
return {};
}
}
public async run({
workspacePath,
values,
logStream,
}: {
workspacePath: string;
values: JsonObject;
logStream: Writable;
}): Promise<void> {
const templateDir = path.join(workspacePath, 'template');
const intermediateDir = path.join(workspacePath, 'intermediate');
await fs.ensureDir(intermediateDir);
const resultDir = path.join(workspacePath, 'result');
// First lets grab the default cookiecutter.json file
const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir);
const { imageName, ...valuesForCookieCutterJson } = values;
const cookieInfo = {
...cookieCutterJson,
...valuesForCookieCutterJson,
};
await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo);
// Directories to bind on container
const mountDirs = {
[templateDir]: '/input',
[intermediateDir]: '/output',
};
// the command-exists package returns `true` or throws an error
const cookieCutterInstalled = await commandExists('cookiecutter').catch(
() => false,
);
if (cookieCutterInstalled) {
await runCommand({
command: 'cookiecutter',
args: ['--no-input', '-o', intermediateDir, templateDir, '--verbose'],
logStream,
});
} else {
await this.containerRunner.runContainer({
imageName: (imageName as string) ?? 'spotify/backstage-cookiecutter',
command: 'cookiecutter',
args: ['--no-input', '-o', '/output', '/input', '--verbose'],
mountDirs,
workingDir: '/input',
// Set the home directory inside the container as something that applications can
// write to, otherwise they will just fail trying to write to /
envVars: { HOME: '/tmp' },
logStream,
});
}
// if cookiecutter was successful, intermediateDir will contain
// exactly one directory.
const [generated] = await fs.readdir(intermediateDir);
if (generated === undefined) {
throw new Error('No data generated by cookiecutter');
}
await fs.move(path.join(intermediateDir, generated), resultDir);
}
}
export function createFetchCookiecutterAction(options: {
reader: UrlReader;
integrations: ScmIntegrations;
templaters: TemplaterBuilder;
containerRunner: ContainerRunner;
}) {
const { reader, templaters, integrations } = options;
const { reader, containerRunner, integrations } = options;
return createTemplateAction<{
url: string;
@@ -121,9 +215,9 @@ export function createFetchCookiecutterAction(options: {
outputPath: templateContentsDir,
});
const cookiecutter = templaters.get('cookiecutter');
const cookiecutter = new CookiecutterRunner({ containerRunner });
const values = {
...(ctx.input.values as TemplaterValues),
...ctx.input.values,
_copy_without_render: ctx.input.copyWithoutRender,
_extensions: ctx.input.extensions,
imageName: ctx.input.imageName,
@@ -1,5 +1,9 @@
/*
<<<<<<< HEAD:plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts
* Copyright 2020 The Backstage Authors
=======
* Copyright 2021 The Backstage Authors
>>>>>>> breaking: removing alphav1 support for templates:plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,11 +18,48 @@
* limitations under the License.
*/
import { spawn } from 'child_process';
import { PassThrough, Writable } from 'stream';
import globby from 'globby';
import { Logger } from 'winston';
import { Git } from '@backstage/backend-common';
import { Octokit } from '@octokit/rest';
export type RunCommandOptions = {
command: string;
args: string[];
logStream?: Writable;
};
export const runCommand = async ({
command,
args,
logStream = new PassThrough(),
}: RunCommandOptions) => {
await new Promise<void>((resolve, reject) => {
const process = spawn(command, args);
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(`Command ${command} failed, exit code: ${code}`);
}
return resolve();
});
});
};
export async function initRepoAndPush({
dir,
remoteUrl,
@@ -16,7 +16,7 @@
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
@@ -20,7 +20,7 @@ import {
ScmIntegrationRegistry,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
import { createTemplateAction } from '../../createTemplateAction';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { Config } from '@backstage/config';
@@ -22,7 +22,7 @@ import { Octokit } from '@octokit/rest';
import {
enableBranchProtectionOnDefaultRepoBranch,
initRepoAndPush,
} from '../../../stages/publish/helpers';
} from '../helpers';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
import { Config } from '@backstage/config';
@@ -17,7 +17,7 @@
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { Gitlab } from '@gitbeaker/node';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
import { Config } from '@backstage/config';
@@ -13,7 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createLegacyActions } from './stages/legacy';
export * from './stages';
export * from './jobs';
export * from './actions';
@@ -1,17 +0,0 @@
/*
* Copyright 2020 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 * from './processor';
export * from './types';
@@ -1,49 +0,0 @@
/*
* Copyright 2020 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 { makeLogStream } from './logger';
describe('Logger', () => {
const mockMeta = { test: 'blob' };
it('should return empty log lines by default', async () => {
const { log } = makeLogStream(mockMeta);
expect(log).toEqual([]);
});
it('should add lines to the log when using the logger that is returned', async () => {
const { logger, log } = makeLogStream(mockMeta);
logger.info('TEST LINE');
logger.warn('WARN LINE');
const [first, second] = log;
expect(log.length).toBe(2);
expect(first).toContain('info');
expect(first).toContain('TEST LINE');
expect(second).toContain('warn');
expect(second).toContain('WARN LINE');
});
it('should add lines from writing to the stream that is returned', async () => {
const { stream, log } = makeLogStream(mockMeta);
const textLine = 'SOMETHING';
stream.write(textLine);
expect(log).toContain(textLine);
});
});
@@ -1,48 +0,0 @@
/*
* Copyright 2020 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 * as winston from 'winston';
import { JsonValue } from '@backstage/config';
export const makeLogStream = (meta: Record<string, JsonValue>) => {
const log: string[] = [];
// Create an empty stream to collect all the log lines into
// one variable for the API.
const stream = new PassThrough();
stream.on('data', chunk => {
const textValue = chunk.toString().trim();
if (textValue?.length > 1) log.push(textValue);
});
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.simple(),
),
defaultMeta: meta,
});
logger.add(new winston.transports.Stream({ stream }));
return {
log,
stream,
logger,
};
};
@@ -1,329 +0,0 @@
/*
* Copyright 2020 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 { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import parseGitUrl from 'git-url-parse';
import mockFs from 'mock-fs';
import os from 'os';
import { RequiredTemplateValues } from '../stages/templater';
import { makeLogStream } from './logger';
import { JobProcessor } from './processor';
import { StageInput } from './types';
describe('JobProcessor', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'example@email.com',
},
};
const mockValues: RequiredTemplateValues = {
owner: 'blobby',
storePath: 'https://github.com/backstage/mock-repo',
destination: {
git: parseGitUrl('https://github.com/backstage/mock-repo'),
},
};
const workingDirectory = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
// NOTE(freben): Without this line, mock-fs makes winston/logform break.
// There are a number of reported issues with logform and its use of dynamic
// strings for imports. It confuses webpack. The basic fix is to trigger
// those imports before mock-fs runs. I wanted to add a mock dir
// 'node_modules': mockFs.passthrough(), but that doesn't seem to be a thing
// in mock-fs 4.
// Probable REAL fix: https://github.com/winstonjs/logform/pull/117
makeLogStream({});
beforeEach(() => {
mockFs({
[workingDirectory]: mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
describe('create', () => {
it('creates should create a new job with a unique id', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(job.id).toMatch(
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
);
});
it('should setup the correct context for the job', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(job.context.entity).toBe(mockEntity);
expect(job.context.values).toBe(mockValues);
});
it('should set the status as pending', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(job.status).toBe('PENDING');
});
it('should create the correct stages', async () => {
const stages: StageInput[] = [
{
name: 'Do something cool step 1',
handler: jest.fn(),
},
{
name: 'Do something cool step 2',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
expect(job.stages).toHaveLength(stages.length);
for (let i = 0; i < job.stages.length; i++) {
expect(job.stages[i].name).toBe(stages[i].name);
expect(job.stages[i].status).toBe('PENDING');
}
});
});
describe('get', () => {
it('return undefined for when the job does not exist', () => {
const processor = new JobProcessor(workingDirectory);
expect(processor.get('123')).not.toBeDefined();
});
it('should return the exact same instance of the job when one is created', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(processor.get(job.id)).toBe(job);
});
});
describe('process', () => {
it('throws an error when the status of the job is not in pending state', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
job.status = 'STARTED';
await expect(processor.run(job)).rejects.toThrow(
/Job is not in a 'PENDING' state/,
);
});
it('will call each of the handlers in the stages', async () => {
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest.fn(),
},
{
name: 'g/p',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
for (const stage of stages) {
expect(stage.handler).toHaveBeenCalled();
}
});
it('should set all stages to complete and the job to complete when finishes without errors', async () => {
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest.fn(),
},
{
name: 'g/p',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
for (const stage of job.stages) {
expect(stage.status).toBe('COMPLETED');
}
expect(job.status).toBe('COMPLETED');
});
it('should merge the return value from previous steps into the context of the next step', async () => {
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest
.fn()
.mockResolvedValue({ first: 'ben', second: 'lambert' }),
},
{
name: 'g/p',
handler: jest
.fn()
.mockResolvedValue({ second: 'linus', third: 'lambert' }),
},
{
name: 'go',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
expect(stages[1].handler).toHaveBeenCalledWith(
expect.objectContaining({ first: 'ben', second: 'lambert' }),
);
expect(stages[2].handler).toHaveBeenCalledWith(
expect.objectContaining({
first: 'ben',
second: 'linus',
third: 'lambert',
}),
);
});
it('should fail the job and the step if one of them fails', async () => {
const fail = new Error('something went wrong here');
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest.fn(),
},
{
name: 'g/p',
handler: jest.fn().mockRejectedValue(fail),
},
{
name: 'go',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
expect(job.status).toBe('FAILED');
expect(job.stages[0].status).toBe('COMPLETED');
expect(job.stages[1].status).toBe('FAILED');
expect(job.stages[2].status).toBe('PENDING');
expect(job.error?.message).toBe('something went wrong here');
expect(job.stages[1].log.join()).toContain('something went wrong here');
});
});
});
@@ -1,180 +0,0 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { Processor, Job, StageContext, StageInput } from './types';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import * as uuid from 'uuid';
import path from 'path';
import { TemplaterValues } from '../stages/templater';
import { makeLogStream } from './logger';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
export type JobAndDirectoryTuple = {
job: Job;
directory: string;
};
export class JobProcessor implements Processor {
private readonly workingDirectory: string;
private readonly jobs: Map<string, Job>;
static async fromConfig({
config,
logger,
}: {
config: Config;
logger: Logger;
}) {
let workingDirectory: string;
if (config.has('backend.workingDirectory')) {
workingDirectory = config.getString('backend.workingDirectory');
try {
// Check if working directory exists and is writable
await fs.promises.access(
workingDirectory,
fs.constants.F_OK | fs.constants.W_OK,
);
logger.info(`using working directory: ${workingDirectory}`);
} catch (err) {
logger.error(
`working directory ${workingDirectory} ${
err.code === 'ENOENT' ? 'does not exist' : 'is not writable'
}`,
);
throw err;
}
} else {
workingDirectory = os.tmpdir();
}
return new JobProcessor(workingDirectory);
}
constructor(workingDirectory: string) {
this.workingDirectory = workingDirectory;
this.jobs = new Map<string, Job>();
}
create({
entity,
values,
stages,
}: {
entity: TemplateEntityV1alpha1;
values: TemplaterValues;
stages: StageInput[];
}): Job {
const id = uuid.v4();
const { logger, stream } = makeLogStream({ id });
const context: StageContext = {
entity,
values,
logger,
logStream: stream,
workspacePath: path.join(this.workingDirectory, id),
};
const job: Job = {
id,
context,
stages: stages.map(stage => ({
handler: stage.handler,
log: [],
name: stage.name,
status: 'PENDING',
})),
status: 'PENDING',
};
this.jobs.set(job.id, job);
return job;
}
get(id: string): Job | undefined {
return this.jobs.get(id);
}
async run(job: Job): Promise<void> {
if (job.status !== 'PENDING') {
throw new Error("Job is not in a 'PENDING' state");
}
await fs.mkdir(job.context.workspacePath);
job.status = 'STARTED';
try {
for (const stage of job.stages) {
// Create a logger for each stage so we can create separate
// Streams for each step.
const { logger, log, stream } = makeLogStream({
id: job.id,
stage: stage.name,
});
// Attach the logger to the stage, and setup some timestamps.
stage.log = log;
stage.startedAt = Date.now();
try {
// Run the handler with the context created for the Job and some
// Additional logging helpers.
stage.status = 'STARTED';
const handlerResponse = await stage.handler({
...job.context,
logger,
logStream: stream,
});
// If the handler returns something, then let's merge this onto the
// context for the next stage to use as it might be relevant.
if (handlerResponse) {
job.context = {
...job.context,
...handlerResponse,
};
}
// Complete the current stage
stage.status = 'COMPLETED';
} catch (error) {
// Log to the current stage the error that occurred and fail the stage.
stage.status = 'FAILED';
logger.error(`Stage failed with error: ${error.message}`);
logger.debug(error.stack);
// Throw the error so the job can be failed too.
throw error;
} finally {
// Always set the stage end timestamp.
stage.endedAt = Date.now();
}
}
// If all went to plan, complete the job.
job.status = 'COMPLETED';
} catch (error) {
// If something went wrong, fail the job, and set the error property on the job.
job.error = { name: error.name, message: error.message };
job.status = 'FAILED';
} finally {
await fs.remove(job.context.workspacePath);
}
}
}
@@ -1,68 +0,0 @@
/*
* Copyright 2020 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 type { Writable } from 'stream';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { TemplaterValues } from '../stages/templater';
import { Logger } from 'winston';
// Context will be a mutable object which is passed between stages
// To share data, but also thinking that we can pass in functions here too
// To maybe create sub steps or fail the entire thing, or skip stages down the line.
export type StageContext<T = {}> = {
values: TemplaterValues;
entity: TemplateEntityV1alpha1;
logger: Logger;
logStream: Writable;
workspacePath: string;
} & T;
export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
export interface StageResult extends StageInput {
log: string[];
status: ProcessorStatus;
startedAt?: number;
endedAt?: number;
}
export interface StageInput<T = {}> {
name: string;
handler(ctx: StageContext<T>): Promise<void | object>;
}
export type Job = {
id: string;
context: StageContext;
status: ProcessorStatus;
stages: StageResult[];
error?: Error;
};
export type Processor = {
create({
entity,
values,
stages,
}: {
entity: TemplateEntityV1alpha1;
values: TemplaterValues;
stages: StageInput[];
}): Job;
get(id: string): Job | undefined;
run(job: Job): Promise<void>;
};
@@ -1,332 +0,0 @@
/*
* Copyright 2020 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 {
LOCATION_ANNOTATION,
TemplateEntityV1alpha1,
} from '@backstage/catalog-model';
import { joinGitUrlPath, parseLocationAnnotation } from './helpers';
describe('Helpers', () => {
describe('parseLocationAnnotation', () => {
it('throws an exception when no annotation location', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-d@example.com',
},
};
expect(() => parseLocationAnnotation(mockEntity)).toThrow(
expect.objectContaining({
name: 'InputError',
message: `No location annotation provided in entity: ${mockEntity.metadata.name}`,
}),
);
});
it('should throw an error when the protocol part is not set in the location annotation', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]:
':https://github.com/o/r/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-b@example.com',
},
};
expect(() => parseLocationAnnotation(mockEntity)).toThrow(
expect.objectContaining({
name: 'TypeError',
message:
"Unable to parse location reference ':https://github.com/o/r/blob/master/template.yaml', expected '<type>:<target>', e.g. 'url:https://host/path'",
}),
);
});
it('should throw an error when the location part is not set in the location annotation', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]: 'github:',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-a@example.com',
},
};
expect(() => parseLocationAnnotation(mockEntity)).toThrow(
expect.objectContaining({
name: 'TypeError',
message: `Unable to parse location reference 'github:', expected '<type>:<target>', e.g. 'url:https://host/path'`,
}),
);
});
it('should parse the location and protocol correctly for simple locations', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]: 'file:./path',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-b@example.com',
},
};
expect(parseLocationAnnotation(mockEntity)).toEqual({
protocol: 'file',
location: './path',
});
});
it('should parse the location and protocol correctly for complex with unescaped locations', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]: 'github:https://lol.com/:something/shello',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-c@example.com',
},
};
expect(parseLocationAnnotation(mockEntity)).toEqual({
protocol: 'github',
location: 'https://lol.com/:something/shello',
});
});
});
describe('joinGitUrlPath', () => {
it.each([
[
'https://github.com/o/r/blob/master/template.yaml',
'template',
'https://github.com/o/r/blob/master/template',
],
[
'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml',
undefined,
'https://dev.azure.com/o/p/_git/template-repo?path=%2F',
],
[
'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml',
'a',
'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa',
],
[
'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Ftemplate.yaml',
'b',
'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Fb',
],
[
'https://github.com/o/r/blob/master/template.yaml',
undefined,
'https://github.com/o/r/blob/master',
],
[
'https://github.com/o/r/blob/master/template.yaml',
'template',
'https://github.com/o/r/blob/master/template',
],
[
'https://github.com/o/r/blob/master/templates/graphql-starter/template.yaml',
'template',
'https://github.com/o/r/blob/master/templates/graphql-starter/template',
],
[
'https://gitlab.com/o/r/-/blob/master/template.yaml',
undefined,
'https://gitlab.com/o/r/-/blob/master',
],
[
'https://gitlab.com/o/r/-/blob/master/template.yaml',
'template',
'https://gitlab.com/o/r/-/blob/master/template',
],
[
'https://gitlab.com/o/r/-/blob/master/a/b/c/template.yaml',
'../../c',
'https://gitlab.com/o/r/-/blob/master/a/c',
],
[
'https://bitbucket.org/p/r/src/master/a/b/template.yaml',
undefined,
'https://bitbucket.org/p/r/src/master/a/b',
],
[
'https://bitbucket.org/p/r/src/master/a/b/template.yaml',
'c',
'https://bitbucket.org/p/r/src/master/a/b/c',
],
[
'https://bitbucket.org/p/r/src/master/a/b/template.yaml',
'../c',
'https://bitbucket.org/p/r/src/master/a/c',
],
])('should join git url %s with path %s', (url, path, result) => {
expect(joinGitUrlPath(url, path)).toBe(result);
});
});
});
@@ -1,62 +0,0 @@
/*
* Copyright 2020 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 { InputError } from '@backstage/errors';
import {
LOCATION_ANNOTATION,
parseLocationReference,
TemplateEntityV1alpha1,
} from '@backstage/catalog-model';
import { posix as posixPath } from 'path';
export type ParsedLocationAnnotation = {
protocol: 'file' | 'url';
location: string;
};
export const parseLocationAnnotation = (
entity: TemplateEntityV1alpha1,
): ParsedLocationAnnotation => {
const annotation = entity.metadata.annotations?.[LOCATION_ANNOTATION];
if (!annotation) {
throw new InputError(
`No location annotation provided in entity: ${entity.metadata.name}`,
);
}
const { type, target } = parseLocationReference(annotation);
return {
protocol: type as 'file' | 'url',
location: target,
};
};
export function joinGitUrlPath(repoUrl: string, path?: string): string {
const parsed = new URL(repoUrl);
if (parsed.hostname.endsWith('azure.com')) {
const templatePath = posixPath.normalize(
posixPath.join(
posixPath.dirname(parsed.searchParams.get('path') || '/'),
path || '.',
),
);
parsed.searchParams.set('path', templatePath);
return parsed.toString();
}
return new URL(path || '.', repoUrl).toString().replace(/\/$/, '');
}
@@ -1,19 +0,0 @@
/*
* Copyright 2020 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 * from './prepare';
export * from './publish';
export * from './templater';
export * from './helpers';
@@ -1,105 +0,0 @@
/*
* 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 '../actions';
import { FilePreparer, PreparerBuilder } from './prepare';
import { PublisherBuilder } from './publish';
import { TemplaterBuilder, TemplaterValues } from './templater';
type Options = {
preparers: PreparerBuilder;
templaters: TemplaterBuilder;
publishers: PublisherBuilder;
};
export function createLegacyActions(options: Options) {
const { preparers, templaters, publishers } = options;
return [
createTemplateAction({
id: 'legacy:prepare',
async handler(ctx) {
ctx.logger.info('Preparing the skeleton');
const { protocol, url } = ctx.input;
const preparer =
protocol === 'file'
? new FilePreparer()
: preparers.get(url as string);
await preparer.prepare({
url: url as string,
logger: ctx.logger,
workspacePath: ctx.workspacePath,
});
},
}),
createTemplateAction({
id: 'legacy:template',
async handler(ctx) {
ctx.logger.info('Running the templater');
const templater = templaters.get(ctx.input.templater as string);
await templater.run({
workspacePath: ctx.workspacePath,
logStream: ctx.logStream,
values: ctx.input.values as TemplaterValues,
});
},
}),
createTemplateAction({
id: 'legacy:publish',
async handler(ctx) {
const { values } = ctx.input;
if (
typeof values !== 'object' ||
values === null ||
Array.isArray(values)
) {
throw new Error(
`Invalid values passed to publish, got ${typeof values}`,
);
}
const storePath = values.storePath as unknown;
if (typeof storePath !== 'string') {
throw new Error(
`Invalid store path passed to publish, got ${typeof storePath}`,
);
}
const owner = values.owner as unknown;
if (typeof owner !== 'string') {
throw new Error(
`Invalid owner passed to publish, got ${typeof owner}`,
);
}
const publisher = publishers.get(storePath);
ctx.logger.info('Will now store the template');
const { remoteUrl, catalogInfoUrl } = await publisher.publish({
values: {
...values,
owner,
storePath,
},
workspacePath: ctx.workspacePath,
logger: ctx.logger,
});
ctx.output('remoteUrl', remoteUrl);
if (catalogInfoUrl) {
ctx.output('catalogInfoUrl', catalogInfoUrl);
}
},
}),
];
}
@@ -1,116 +0,0 @@
/*
* Copyright 2020 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';
import os from 'os';
import path from 'path';
import { AzurePreparer } from './azure';
import { getVoidLogger, Git } from '@backstage/backend-common';
jest.mock('fs-extra');
describe('AzurePreparer', () => {
const mockGitClient = {
clone: jest.fn(),
};
const logger = getVoidLogger();
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
const preparer = AzurePreparer.fromConfig({
host: 'dev.azure.com',
token: 'fake-azure-token',
});
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const checkoutPath = path.resolve(workspacePath, 'checkout');
const templatePath = path.resolve(workspacePath, 'template');
const prepareOptions = {
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
workspacePath,
logger,
};
it('calls the clone command with token from integrations config', async () => {
await preparer.prepare(prepareOptions);
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
password: 'fake-azure-token',
username: 'notempty',
});
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
});
it('calls the clone command with the correct arguments for a repository', async () => {
await preparer.prepare(prepareOptions);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
dir: checkoutPath,
});
});
it('calls the clone command with the correct arguments for a repository with a specified branch', async () => {
await preparer.prepare({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml&version=GBmaster',
logger,
workspacePath,
});
expect(mockGitClient.clone).toHaveBeenCalledWith({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
dir: checkoutPath,
ref: 'master',
});
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
await preparer.prepare({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
workspacePath,
logger,
});
expect(mockGitClient.clone).toHaveBeenCalledWith({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
dir: checkoutPath,
});
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
});
it('moves the template from path if it is specified', async () => {
await preparer.prepare({
url: `https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=${encodeURIComponent(
'./subdir',
)}`,
logger,
workspacePath,
});
expect(fs.move).toHaveBeenCalledWith(
path.resolve(checkoutPath, 'subdir'),
templatePath,
);
});
});
@@ -1,63 +0,0 @@
/*
* Copyright 2020 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';
import path from 'path';
import { Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import parseGitUrl from 'git-url-parse';
import { AzureIntegrationConfig } from '@backstage/integration';
export class AzurePreparer implements PreparerBase {
static fromConfig(config: AzureIntegrationConfig) {
return new AzurePreparer({ token: config.token });
}
constructor(private readonly config: { token?: string }) {}
async prepare({ url, workspacePath, logger }: PreparerOptions) {
const parsedGitUrl = parseGitUrl(url);
const checkoutPath = path.join(workspacePath, 'checkout');
const targetPath = path.join(workspacePath, 'template');
const fullPathToTemplate = path.resolve(
checkoutPath,
parsedGitUrl.filepath ?? '',
);
// Username can be anything but the empty string according to:
// https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat
const git = this.config.token
? Git.fromAuth({
password: this.config.token,
username: 'notempty',
logger,
})
: Git.fromAuth({ logger });
await git.clone({
url: parsedGitUrl.toString('https'),
ref: parsedGitUrl.ref,
dir: checkoutPath,
});
await fs.move(fullPathToTemplate, targetPath);
try {
await fs.rmdir(path.join(targetPath, '.git'));
} catch {
// Ignore intentionally
}
}
}
@@ -1,113 +0,0 @@
/*
* Copyright 2020 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';
import { BitbucketPreparer } from './bitbucket';
import { getVoidLogger, Git } from '@backstage/backend-common';
import path from 'path';
import os from 'os';
jest.mock('fs-extra');
describe('BitbucketPreparer', () => {
const logger = getVoidLogger();
const mockGitClient = {
clone: jest.fn(),
};
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
beforeEach(() => {
jest.clearAllMocks();
});
const preparer = BitbucketPreparer.fromConfig({
host: 'bitbucket.org',
username: 'fake-user',
appPassword: 'fake-password',
});
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const checkoutPath = path.resolve(workspacePath, 'checkout');
const templatePath = path.resolve(workspacePath, 'template');
const prepareOptions = {
url: 'https://bitbucket.org/backstage-project/backstage-repo',
logger,
workspacePath,
};
it('calls the clone command with the correct arguments for a repository', async () => {
await preparer.prepare(prepareOptions);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://bitbucket.org/backstage-project/backstage-repo',
dir: checkoutPath,
ref: expect.any(String),
});
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
});
it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => {
const preparerCheck = BitbucketPreparer.fromConfig({
host: 'bitbucket.org',
username: 'fake-user',
appPassword: 'fake-password',
});
await preparerCheck.prepare(prepareOptions);
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'fake-user',
password: 'fake-password',
});
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
await preparer.prepare(prepareOptions);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://bitbucket.org/backstage-project/backstage-repo',
dir: checkoutPath,
ref: expect.any(String),
});
});
it('moves a template subdirectory to checkout if specified', async () => {
await preparer.prepare({
url: 'https://bitbucket.org/foo/bar/src/master/1/2/3',
logger,
workspacePath,
});
expect(fs.move).toHaveBeenCalledWith(
path.resolve(checkoutPath, '1', '2', '3'),
templatePath,
);
});
it('calls the clone command with with token for auth method', async () => {
const preparerCheck = BitbucketPreparer.fromConfig({
host: 'bitbucket.org',
token: 'fake-token',
});
await preparerCheck.prepare(prepareOptions);
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'x-token-auth',
password: 'fake-token',
});
});
});
@@ -1,82 +0,0 @@
/*
* Copyright 2020 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';
import path from 'path';
import { Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import { BitbucketIntegrationConfig } from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
export class BitbucketPreparer implements PreparerBase {
static fromConfig(config: BitbucketIntegrationConfig) {
return new BitbucketPreparer({
username: config.username,
token: config.token,
appPassword: config.appPassword,
});
}
constructor(
private readonly config: {
username?: string;
token?: string;
appPassword?: string;
},
) {}
async prepare({ url, workspacePath, logger }: PreparerOptions) {
const parsedGitUrl = parseGitUrl(url);
const checkoutPath = path.join(workspacePath, 'checkout');
const targetPath = path.join(workspacePath, 'template');
const fullPathToTemplate = path.resolve(
checkoutPath,
parsedGitUrl.filepath ?? '',
);
const git = Git.fromAuth({ logger, ...this.getAuth() });
await git.clone({
url: parsedGitUrl.toString('https'),
dir: checkoutPath,
ref: parsedGitUrl.ref,
});
await fs.move(fullPathToTemplate, targetPath);
try {
await fs.rmdir(path.join(targetPath, '.git'));
} catch {
// Ignore intentionally
}
}
private getAuth(): { username: string; password: string } | undefined {
const { username, token, appPassword } = this.config;
if (username && appPassword) {
return { username: username, password: appPassword };
}
if (token) {
return {
username: 'x-token-auth',
password: token! || appPassword!,
};
}
return undefined;
}
}
@@ -1,79 +0,0 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { FilePreparer } from './file';
import os from 'os';
import path from 'path';
jest.mock('fs-extra');
describe('File preparer', () => {
it('prepares templates from a file path', async () => {
const logger = getVoidLogger();
const preparer = new FilePreparer();
const root = os.platform() === 'win32' ? 'C:\\' : '/';
const workspacePath = path.join(root, 'tmp');
const targetPath = path.resolve(workspacePath, 'template');
await preparer.prepare({
url: `file://${root}path/to/template`,
logger,
workspacePath,
});
expect(fs.copy).toHaveBeenCalledWith(
path.join(root, 'path', 'to', 'template'),
targetPath,
{
recursive: true,
},
);
expect(fs.ensureDir).toHaveBeenCalledWith(targetPath);
await expect(
preparer.prepare({
url: 'http://not/file/path',
logger,
workspacePath,
}),
).rejects.toThrow(
"Wrong location protocol, should be 'file', http://not/file/path",
);
if (os.platform() === 'win32') {
// eslint-disable-next-line jest/no-conditional-expect
await expect(
preparer.prepare({
url: 'file:///unix/file/path',
logger,
workspacePath,
}),
).rejects.toThrow('File URL path must be absolute');
} else {
// eslint-disable-next-line jest/no-conditional-expect
await expect(
preparer.prepare({
url: 'file://not/full/path',
logger,
workspacePath,
}),
).rejects.toThrow(
`File URL host must be "localhost" or empty on ${os.platform()}`,
);
}
});
});
@@ -1,37 +0,0 @@
/*
* Copyright 2020 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';
import path from 'path';
import { fileURLToPath } from 'url';
import { InputError } from '@backstage/errors';
import { PreparerBase, PreparerOptions } from './types';
export class FilePreparer implements PreparerBase {
async prepare({ url, workspacePath }: PreparerOptions) {
if (!url.startsWith('file://')) {
throw new InputError(`Wrong location protocol, should be 'file', ${url}`);
}
const templatePath = fileURLToPath(url);
const targetDir = path.join(workspacePath, 'template');
await fs.ensureDir(targetDir);
await fs.copy(templatePath, targetDir, {
recursive: true,
});
}
}
@@ -1,97 +0,0 @@
/*
* Copyright 2020 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';
import os from 'os';
import path from 'path';
import { GithubPreparer } from './github';
import { getVoidLogger, Git } from '@backstage/backend-common';
jest.mock('fs-extra');
describe('GitHubPreparer', () => {
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const checkoutPath = path.resolve(workspacePath, 'checkout');
const templatePath = path.resolve(workspacePath, 'template');
const mockGitClient = {
clone: jest.fn(),
};
const logger = getVoidLogger();
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
beforeEach(() => {
jest.clearAllMocks();
});
const preparer = GithubPreparer.fromConfig({
host: 'github.com',
token: 'fake-token',
});
it('calls the clone command with the correct arguments for a repository', async () => {
await preparer.prepare({
url:
'https://github.com/benjdlambert/backstage-graphql-template/blob/master/templates/graphql-starter/template',
logger,
workspacePath,
});
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://github.com/benjdlambert/backstage-graphql-template',
dir: checkoutPath,
ref: expect.any(String),
});
expect(fs.move).toHaveBeenCalledWith(
path.resolve(checkoutPath, 'templates', 'graphql-starter', 'template'),
templatePath,
);
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
await preparer.prepare({
url:
'https://github.com/benjdlambert/backstage-graphql-template/blob/master',
logger,
workspacePath,
});
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://github.com/benjdlambert/backstage-graphql-template',
dir: checkoutPath,
ref: 'master',
});
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
});
it('calls the clone command with token', async () => {
await preparer.prepare({
url:
'https://github.com/benjdlambert/backstage-graphql-template/blob/master',
logger,
workspacePath,
});
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'x-access-token',
password: 'fake-token',
});
});
});
@@ -1,71 +0,0 @@
/*
* Copyright 2020 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';
import path from 'path';
import { Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import parseGitUrl from 'git-url-parse';
import {
GitHubIntegrationConfig,
GithubCredentialsProvider,
} from '@backstage/integration';
export class GithubPreparer implements PreparerBase {
static fromConfig(config: GitHubIntegrationConfig) {
const credentialsProvider = GithubCredentialsProvider.create(config);
return new GithubPreparer({ credentialsProvider });
}
constructor(
private readonly config: { credentialsProvider: GithubCredentialsProvider },
) {}
async prepare({ url, workspacePath, logger }: PreparerOptions) {
const parsedGitUrl = parseGitUrl(url);
const checkoutPath = path.join(workspacePath, 'checkout');
const targetPath = path.join(workspacePath, 'template');
const fullPathToTemplate = path.resolve(
checkoutPath,
parsedGitUrl.filepath ?? '',
);
const { token } = await this.config.credentialsProvider.getCredentials({
url,
});
const git = token
? Git.fromAuth({
username: 'x-access-token',
password: token,
logger,
})
: Git.fromAuth({ logger });
await git.clone({
url: parsedGitUrl.toString('https'),
dir: checkoutPath,
ref: parsedGitUrl.ref,
});
await fs.move(fullPathToTemplate, targetPath);
try {
await fs.rmdir(path.join(targetPath, '.git'));
} catch {
// Ignore intentionally
}
}
}
@@ -1,82 +0,0 @@
/*
* Copyright 2020 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';
import os from 'os';
import path from 'path';
import { GitlabPreparer } from './gitlab';
import { getVoidLogger, Git } from '@backstage/backend-common';
jest.mock('fs-extra');
describe('GitLabPreparer', () => {
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const checkoutPath = path.resolve(workspacePath, 'checkout');
const templatePath = path.resolve(workspacePath, 'template');
const mockGitClient = {
clone: jest.fn(),
};
const logger = getVoidLogger();
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
beforeEach(() => {
jest.clearAllMocks();
});
const preparer = GitlabPreparer.fromConfig({
host: '<ignored>',
token: 'fake-token',
apiBaseUrl: '<ignored>',
baseUrl: '<ignored>',
});
it(`calls the clone command with the correct arguments for a repository`, async () => {
await preparer.prepare({
url:
'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master',
logger,
workspacePath,
});
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://gitlab.com/benjdlambert/backstage-graphql-template.git',
dir: checkoutPath,
ref: expect.any(String),
});
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'oauth2',
password: 'fake-token',
});
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
});
it(`clones the template from a sub directory if specified`, async () => {
await preparer.prepare({
url:
'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/1/2/3',
logger,
workspacePath,
});
expect(fs.move).toHaveBeenCalledWith(
path.resolve(checkoutPath, '1', '2', '3'),
templatePath,
);
});
});
@@ -1,62 +0,0 @@
/*
* Copyright 2020 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 { Git } from '@backstage/backend-common';
import { GitLabIntegrationConfig } from '@backstage/integration';
import fs from 'fs-extra';
import parseGitUrl from 'git-url-parse';
import path from 'path';
import { PreparerBase, PreparerOptions } from './types';
export class GitlabPreparer implements PreparerBase {
static fromConfig(config: GitLabIntegrationConfig) {
return new GitlabPreparer({ token: config.token });
}
constructor(private readonly config: { token?: string }) {}
async prepare({ url, workspacePath, logger }: PreparerOptions) {
const parsedGitUrl = parseGitUrl(url);
const checkoutPath = path.join(workspacePath, 'checkout');
const targetPath = path.join(workspacePath, 'template');
const fullPathToTemplate = path.resolve(
checkoutPath,
parsedGitUrl.filepath ?? '',
);
parsedGitUrl.git_suffix = true;
const git = this.config.token
? Git.fromAuth({
password: this.config.token,
username: 'oauth2',
logger,
})
: Git.fromAuth({ logger });
await git.clone({
url: parsedGitUrl.toString('https'),
dir: checkoutPath,
ref: parsedGitUrl.ref,
});
await fs.move(fullPathToTemplate, targetPath);
try {
await fs.rmdir(path.join(targetPath, '.git'));
} catch {
// Ignore intentionally
}
}
}
@@ -1,23 +0,0 @@
/*
* Copyright 2020 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 { AzurePreparer } from './azure';
export { BitbucketPreparer } from './bitbucket';
export { FilePreparer } from './file';
export { GithubPreparer } from './github';
export { GitlabPreparer } from './gitlab';
export { Preparers } from './preparers';
export type { PreparerBase, PreparerBuilder, PreparerOptions } from './types';
@@ -1,50 +0,0 @@
/*
* Copyright 2020 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 { GithubPreparer } from './github';
import { Preparers } from './preparers';
describe('Preparers', () => {
it('should return the correct preparer based on the hostname', async () => {
const preparer = await GithubPreparer.fromConfig({
host: 'github.com',
apiBaseUrl: 'lols',
token: 'something else yo',
});
const preparers = new Preparers();
preparers.register('github.com', preparer);
expect(
preparers.get('https://github.com/please/find/me/something/from/github'),
).toBe(preparer);
});
it('should throw an error if there is nothing that will match the url provided', async () => {
const preparer = await GithubPreparer.fromConfig({
host: 'github.com',
apiBaseUrl: 'lols',
token: 'something else yo',
});
const preparers = new Preparers();
preparers.register('github.com', preparer);
expect(() => preparers.get('https://404.com')).toThrow(
`Unable to find a preparer for URL: https://404.com. Please make sure to register this host under an integration in app-config`,
);
});
});
@@ -1,81 +0,0 @@
/*
* Copyright 2020 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 { Config } from '@backstage/config';
import { PreparerBase, PreparerBuilder } from './types';
import { Logger } from 'winston';
import { GitlabPreparer } from './gitlab';
import { AzurePreparer } from './azure';
import { GithubPreparer } from './github';
import { BitbucketPreparer } from './bitbucket';
import { ScmIntegrations } from '@backstage/integration';
export class Preparers implements PreparerBuilder {
private preparerMap = new Map<string, PreparerBase>();
register(host: string, preparer: PreparerBase) {
this.preparerMap.set(host, preparer);
}
get(url: string): PreparerBase {
const preparer = this.preparerMap.get(new URL(url).host);
if (!preparer) {
throw new Error(
`Unable to find a preparer for URL: ${url}. Please make sure to register this host under an integration in app-config`,
);
}
return preparer;
}
static async fromConfig(
config: Config,
// eslint-disable-next-line
_: { logger: Logger },
): Promise<PreparerBuilder> {
const preparers = new Preparers();
const scm = ScmIntegrations.fromConfig(config);
for (const integration of scm.azure.list()) {
preparers.register(
integration.config.host,
AzurePreparer.fromConfig(integration.config),
);
}
for (const integration of scm.github.list()) {
preparers.register(
integration.config.host,
GithubPreparer.fromConfig(integration.config),
);
}
for (const integration of scm.gitlab.list()) {
preparers.register(
integration.config.host,
GitlabPreparer.fromConfig(integration.config),
);
}
for (const integration of scm.bitbucket.list()) {
preparers.register(
integration.config.host,
BitbucketPreparer.fromConfig(integration.config),
);
}
return preparers;
}
}
@@ -1,40 +0,0 @@
/*
* Copyright 2020 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 { Logger } from 'winston';
export type PreparerOptions = {
/**
* Full URL to the directory containg template data
*/
url: string;
/**
* The workspace path that will eventually be the the root of the new repo
*/
workspacePath: string;
logger: Logger;
};
export interface PreparerBase {
/**
* Prepare a directory with contents from the remote location
*/
prepare(opts: PreparerOptions): Promise<void>;
}
export type PreparerBuilder = {
register(host: string, preparer: PreparerBase): void;
get(url: string): PreparerBase;
};
@@ -1,89 +0,0 @@
/*
* Copyright 2020 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('./helpers');
jest.mock('azure-devops-node-api', () => ({
WebApi: jest.fn(),
getPersonalAccessTokenHandler: jest.fn().mockReturnValue(() => {}),
}));
import os from 'os';
import { resolve } from 'path';
import { AzurePublisher } from './azure';
import { WebApi } from 'azure-devops-node-api';
import * as helpers from './helpers';
import { getVoidLogger } from '@backstage/backend-common';
describe('Azure Publisher', () => {
const logger = getVoidLogger();
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const resultPath = resolve(workspacePath, 'result');
describe('publish: createRemoteInAzure', () => {
it('should use azure-devops-node-api to create a repo in the given project', async () => {
const mockGitClient = {
createRepository: jest.fn(),
};
const mockGitApi = {
getGitApi: jest.fn().mockReturnValue(mockGitClient),
};
((WebApi as unknown) as jest.Mock).mockImplementation(() => mockGitApi);
const publisher = await AzurePublisher.fromConfig({
host: 'dev.azure.com',
token: 'fake-azure-token',
});
mockGitClient.createRepository.mockResolvedValue({
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
} as { remoteUrl: string });
const result = await publisher!.publish({
values: {
storePath: 'https://dev.azure.com/organisation/project/_git/repo',
owner: 'bob',
},
workspacePath,
logger,
});
expect(WebApi).toHaveBeenCalledWith(
'https://dev.azure.com/organisation',
expect.any(Function),
);
expect(result).toEqual({
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
catalogInfoUrl:
'https://dev.azure.com/organization/project/_git/repo?path=%2Fcatalog-info.yaml',
});
expect(mockGitClient.createRepository).toHaveBeenCalledWith(
{
name: 'repo',
},
'project',
);
expect(helpers.initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
auth: { username: 'notempty', password: 'fake-azure-token' },
logger,
});
});
});
});
@@ -1,83 +0,0 @@
/*
* Copyright 2020 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 { PublisherBase, PublisherOptions, PublisherResult } from './types';
import { IGitApi } from 'azure-devops-node-api/GitApi';
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { initRepoAndPush } from './helpers';
import { AzureIntegrationConfig } from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
import path from 'path';
export class AzurePublisher implements PublisherBase {
static async fromConfig(config: AzureIntegrationConfig) {
if (!config.token) {
return undefined;
}
return new AzurePublisher({ token: config.token });
}
constructor(private readonly config: { token: string }) {}
async publish({
values,
workspacePath,
logger,
}: PublisherOptions): Promise<PublisherResult> {
const { owner, name, organization, resource } = parseGitUrl(
values.storePath,
);
const authHandler = getPersonalAccessTokenHandler(this.config.token);
const webApi = new WebApi(
`https://${resource}/${organization}`,
authHandler,
);
const client = await webApi.getGitApi();
const remoteUrl = await this.createRemote({
project: owner,
name,
client,
});
const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`;
await initRepoAndPush({
dir: path.join(workspacePath, 'result'),
remoteUrl,
auth: {
username: 'notempty',
password: this.config.token,
},
logger,
});
return { remoteUrl, catalogInfoUrl };
}
private async createRemote(opts: {
name: string;
project: string;
client: IGitApi;
}) {
const { name, project, client } = opts;
const createOptions: GitRepositoryCreateOptions = { name };
const repo = await client.createRepository(createOptions, project);
return repo.remoteUrl || '';
}
}
@@ -1,228 +0,0 @@
/*
* Copyright 2020 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('./helpers');
import os from 'os';
import { resolve } from 'path';
import { BitbucketPublisher } from './bitbucket';
import { initRepoAndPush } from './helpers';
import { getVoidLogger } from '@backstage/backend-common';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { msw } from '@backstage/test-utils';
describe('Bitbucket Publisher', () => {
const logger = getVoidLogger();
const server = setupServer();
msw.setupDefaultHandlers(server);
beforeEach(() => {
jest.clearAllMocks();
});
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const resultPath = resolve(workspacePath, 'result');
describe('publish: createRemoteInBitbucketCloud', () => {
it('should create repo in bitbucket cloud', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/project/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/project/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/project/repo',
},
],
},
}),
),
),
);
const publisher = await BitbucketPublisher.fromConfig(
{
host: 'bitbucket.org',
username: 'fake-user',
appPassword: 'fake-token',
},
{ repoVisibility: 'private' },
);
const result = await publisher.publish({
values: {
storePath: 'https://bitbucket.org/project/repo',
owner: 'bob',
},
workspacePath,
logger: logger,
});
expect(result).toEqual({
remoteUrl: 'https://bitbucket.org/project/repo',
catalogInfoUrl:
'https://bitbucket.org/project/repo/src/master/catalog-info.yaml',
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'https://bitbucket.org/project/repo',
auth: { username: 'fake-user', password: 'fake-token' },
logger: logger,
});
});
});
describe('publish: createRemoteInBitbucketServer', () => {
it('should throw an error if no username present', async () => {
await expect(
BitbucketPublisher.fromConfig(
{
host: 'bitbucket.mycompany.com',
token: 'fake-token',
},
{ repoVisibility: 'private' },
),
).rejects.toThrow(
'Bitbucket server requires the username to be set in your config',
);
});
it('should create repo in bitbucket server', async () => {
server.use(
rest.post(
'https://bitbucket.mycompany.com/rest/api/1.0/projects/project/repos',
(_, res, ctx) =>
res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href:
'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
),
),
);
const publisher = await BitbucketPublisher.fromConfig(
{
host: 'bitbucket.mycompany.com',
username: 'foo',
token: 'fake-token',
},
{ repoVisibility: 'private' },
);
const result = await publisher.publish({
values: {
storePath: 'https://bitbucket.mycompany.com/project/repo',
owner: 'bob',
},
workspacePath,
logger: logger,
});
expect(result).toEqual({
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
catalogInfoUrl:
'https://bitbucket.mycompany.com/projects/project/repos/repo/catalog-info.yaml',
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
auth: { username: 'foo', password: 'fake-token' },
logger: logger,
});
});
});
it('should use apiBaseUrl to create the repository if it is set', async () => {
server.use(
rest.post(
'https://bitbucket.mycompany.com/bitbucket/rest/api/1.0/projects/project/repos',
(_, res, ctx) =>
res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href:
'https://bitbucket.mycompany.com/bitbucket/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href:
'https://bitbucket.mycompany.com/bitbucket/scm/project/repo',
},
],
},
}),
),
),
);
const publisher = await BitbucketPublisher.fromConfig(
{
host: 'bitbucket.mycompany.com',
username: 'foo',
token: 'fake-token',
apiBaseUrl: 'https://bitbucket.mycompany.com/bitbucket/rest/api/1.0',
},
{ repoVisibility: 'private' },
);
const result = await publisher.publish({
values: {
storePath: 'https://bitbucket.mycompany.com/project/repo',
owner: 'bob',
},
workspacePath,
logger: logger,
});
expect(result).toEqual({
remoteUrl: 'https://bitbucket.mycompany.com/bitbucket/scm/project/repo',
catalogInfoUrl:
'https://bitbucket.mycompany.com/bitbucket/projects/project/repos/repo/catalog-info.yaml',
});
});
});
@@ -1,207 +0,0 @@
/*
* Copyright 2020 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 { PublisherBase, PublisherOptions, PublisherResult } from './types';
import { initRepoAndPush } from './helpers';
import fetch from 'cross-fetch';
import { BitbucketIntegrationConfig } from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
import path from 'path';
export type RepoVisibilityOptions = 'private' | 'public';
// TODO(blam): We should probably start to use a bitbucket client here that we can change
// the baseURL to point at on-prem or public bitbucket versions like we do for
// github and ghe. There's to much logic and not enough types here for us to say that this way is better than using
// a supported bitbucket client if one exists.
export class BitbucketPublisher implements PublisherBase {
static async fromConfig(
config: BitbucketIntegrationConfig,
{ repoVisibility }: { repoVisibility: RepoVisibilityOptions },
) {
if (config.host !== 'bitbucket.org' && !config.username)
throw new Error(
'Bitbucket server requires the username to be set in your config',
);
return new BitbucketPublisher({
host: config.host,
token: config.token,
appPassword: config.appPassword,
username: config.username,
apiBaseUrl: config.apiBaseUrl,
repoVisibility,
});
}
constructor(
private readonly config: {
host: string;
token?: string;
appPassword?: string;
username?: string;
apiBaseUrl?: string;
repoVisibility: RepoVisibilityOptions;
},
) {}
async publish({
values,
workspacePath,
logger,
}: PublisherOptions): Promise<PublisherResult> {
const { owner: project, name } = parseGitUrl(values.storePath);
const description = values.description as string;
const result = await this.createRemote({
project,
name,
description,
});
await initRepoAndPush({
dir: path.join(workspacePath, 'result'),
remoteUrl: result.remoteUrl,
auth: {
username: this.config.username ? this.config.username : 'x-token-auth',
password: this.config.appPassword
? this.config.appPassword
: this.config.token ?? '',
},
logger,
});
return result;
}
private async createRemote(opts: {
project: string;
name: string;
description: string;
}): Promise<PublisherResult> {
if (this.config.host === 'bitbucket.org') {
return this.createBitbucketCloudRepository(opts);
}
return this.createBitbucketServerRepository(opts);
}
private async createBitbucketCloudRepository(opts: {
project: string;
name: string;
description: string;
}): Promise<PublisherResult> {
const { project, name, description } = opts;
let response: Response;
const options: RequestInit = {
method: 'POST',
body: JSON.stringify({
scm: 'git',
description: description,
is_private: this.config.repoVisibility === 'private',
}),
headers: {
Authorization: this.getAuthorizationHeader(),
'Content-Type': 'application/json',
},
};
try {
response = await fetch(
`https://api.bitbucket.org/2.0/repositories/${project}/${name}`,
options,
);
} catch (e) {
throw new Error(`Unable to create repository, ${e}`);
}
if (response.status === 200) {
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'https') {
remoteUrl = link.href;
}
}
// TODO use the urlReader to get the default branch
const catalogInfoUrl = `${r.links.html.href}/src/master/catalog-info.yaml`;
return { remoteUrl, catalogInfoUrl };
}
throw new Error(`Not a valid response code ${await response.text()}`);
}
private getAuthorizationHeader(): string {
if (this.config.username && this.config.appPassword) {
const buffer = Buffer.from(
`${this.config.username}:${this.config.appPassword}`,
'utf8',
);
return `Basic ${buffer.toString('base64')}`;
}
if (this.config.token) {
return `Bearer ${this.config.token}`;
}
throw new Error(
`Authorization has not been provided for Bitbucket. Please add either username + appPassword or token to the Integrations config`,
);
}
private async createBitbucketServerRepository(opts: {
project: string;
name: string;
description: string;
}): Promise<PublisherResult> {
const { project, name, description } = opts;
let response: Response;
const options: RequestInit = {
method: 'POST',
body: JSON.stringify({
name: name,
description: description,
public: this.config.repoVisibility === 'public',
}),
headers: {
Authorization: this.getAuthorizationHeader(),
'Content-Type': 'application/json',
},
};
try {
const baseUrl = this.config.apiBaseUrl
? this.config.apiBaseUrl
: `https://${this.config.host}/rest/api/1.0`;
response = await fetch(`${baseUrl}/projects/${project}/repos`, options);
} catch (e) {
throw new Error(`Unable to create repository, ${e}`);
}
if (response.status === 201) {
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'http') {
remoteUrl = link.href;
}
}
const catalogInfoUrl = `${r.links.self[0].href}/catalog-info.yaml`;
return { remoteUrl, catalogInfoUrl };
}
throw new Error(`Not a valid response code ${await response.text()}`);
}
}
@@ -1,315 +0,0 @@
/*
* Copyright 2020 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('@octokit/rest');
jest.mock('./helpers');
import os from 'os';
import { resolve } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
import { GithubPublisher } from './github';
import { initRepoAndPush } from './helpers';
const { mockGithubClient } = require('@octokit/rest') as {
mockGithubClient: {
repos: jest.Mocked<Octokit['repos']>;
users: jest.Mocked<Octokit['users']>;
teams: jest.Mocked<Octokit['teams']>;
};
};
describe('GitHub Publisher', () => {
const logger = getVoidLogger();
beforeEach(() => {
jest.clearAllMocks();
});
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const resultPath = resolve(workspacePath, 'result');
describe('with public repo visibility', () => {
describe('publish: createRemoteInGithub', () => {
it('should use octokit to create a repo in an organisation if the organisation property is set', async () => {
const publisher = await GithubPublisher.fromConfig(
{
token: 'fake-token',
host: 'github.com',
},
{ repoVisibility: 'public' },
);
mockGithubClient.repos.createInOrg.mockResolvedValue({
data: {
clone_url: 'https://github.com/backstage/backstage.git',
},
} as RestEndpointMethodTypes['repos']['createInOrg']['response']);
mockGithubClient.users.getByUsername.mockResolvedValue({
data: {
type: 'Organization',
},
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
const result = await publisher!.publish({
values: {
storePath: 'https://github.com/blam/test',
owner: 'bob',
access: 'blam/team',
},
workspacePath,
logger,
});
expect(result).toEqual({
remoteUrl: 'https://github.com/backstage/backstage.git',
catalogInfoUrl:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
});
expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({
org: 'blam',
name: 'test',
private: false,
visibility: 'public',
});
expect(
mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg,
).toHaveBeenCalledWith({
org: 'blam',
team_slug: 'team',
owner: 'blam',
repo: 'test',
permission: 'admin',
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'x-access-token', password: 'fake-token' },
logger,
});
});
it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => {
const publisher = await GithubPublisher.fromConfig(
{
token: 'fake-token',
host: 'github.com',
},
{ repoVisibility: 'public' },
);
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/backstage/backstage.git',
},
} as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']);
mockGithubClient.users.getByUsername.mockResolvedValue({
data: {
type: 'User',
},
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
const result = await publisher!.publish({
values: {
storePath: 'https://github.com/blam/test',
owner: 'bob',
access: 'blam',
},
workspacePath,
logger,
});
expect(result).toEqual({
remoteUrl: 'https://github.com/backstage/backstage.git',
catalogInfoUrl:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
});
expect(
mockGithubClient.repos.createForAuthenticatedUser,
).toHaveBeenCalledWith({
name: 'test',
private: false,
});
expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled();
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'x-access-token', password: 'fake-token' },
logger,
});
});
});
it('should invite other user in the authed user', async () => {
const publisher = await GithubPublisher.fromConfig(
{
token: 'fake-token',
host: 'github.com',
},
{ repoVisibility: 'public' },
);
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/backstage/backstage.git',
},
} as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']);
mockGithubClient.users.getByUsername.mockResolvedValue({
data: {
type: 'User',
},
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
const result = await publisher!.publish({
values: {
storePath: 'https://github.com/blam/test',
owner: 'bob',
access: 'bob',
description: 'description',
},
workspacePath,
logger,
});
expect(result).toEqual({
remoteUrl: 'https://github.com/backstage/backstage.git',
catalogInfoUrl:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
});
expect(
mockGithubClient.repos.createForAuthenticatedUser,
).toHaveBeenCalledWith({
description: 'description',
name: 'test',
private: false,
});
expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({
owner: 'blam',
repo: 'test',
username: 'bob',
permission: 'admin',
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'x-access-token', password: 'fake-token' },
logger,
});
});
});
describe('with internal repo visibility', () => {
it('creates a private repository in the organization with visibility set to internal', async () => {
const publisher = await GithubPublisher.fromConfig(
{
token: 'fake-token',
host: 'github.com',
},
{ repoVisibility: 'internal' },
);
mockGithubClient.repos.createInOrg.mockResolvedValue({
data: {
clone_url: 'https://github.com/backstage/backstage.git',
},
} as RestEndpointMethodTypes['repos']['createInOrg']['response']);
mockGithubClient.users.getByUsername.mockResolvedValue({
data: {
type: 'Organization',
},
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
const result = await publisher!.publish({
values: {
isOrg: true,
storePath: 'https://github.com/blam/test',
owner: 'bob',
},
workspacePath,
logger,
});
expect(result).toEqual({
remoteUrl: 'https://github.com/backstage/backstage.git',
catalogInfoUrl:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
});
expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({
org: 'blam',
name: 'test',
private: true,
visibility: 'internal',
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'x-access-token', password: 'fake-token' },
logger,
});
});
});
describe('private visibility in a user account', () => {
it('creates a private repository', async () => {
const publisher = await GithubPublisher.fromConfig(
{
token: 'fake-token',
host: 'github.com',
},
{ repoVisibility: 'private' },
);
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/backstage/backstage.git',
},
} as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']);
mockGithubClient.users.getByUsername.mockResolvedValue({
data: {
type: 'User',
},
} as RestEndpointMethodTypes['users']['getByUsername']['response']);
const result = await publisher!.publish({
values: {
storePath: 'https://github.com/blam/test',
owner: 'bob',
},
workspacePath,
logger,
});
expect(result).toEqual({
remoteUrl: 'https://github.com/backstage/backstage.git',
catalogInfoUrl:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
});
expect(
mockGithubClient.repos.createForAuthenticatedUser,
).toHaveBeenCalledWith({
name: 'test',
private: true,
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'x-access-token', password: 'fake-token' },
logger,
});
});
});
});
@@ -1,178 +0,0 @@
/*
* Copyright 2020 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 { PublisherBase, PublisherOptions, PublisherResult } from './types';
import {
enableBranchProtectionOnDefaultRepoBranch,
initRepoAndPush,
} from './helpers';
import {
GitHubIntegrationConfig,
GithubCredentialsProvider,
} from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
import { Octokit } from '@octokit/rest';
import path from 'path';
export type RepoVisibilityOptions = 'private' | 'internal' | 'public';
/** @deprecated use createPublishGithubAction instead */
export class GithubPublisher implements PublisherBase {
static async fromConfig(
config: GitHubIntegrationConfig,
{ repoVisibility }: { repoVisibility: RepoVisibilityOptions },
) {
if (!config.token && !config.apps) {
return undefined;
}
const credentialsProvider = GithubCredentialsProvider.create(config);
return new GithubPublisher({
credentialsProvider,
repoVisibility,
apiBaseUrl: config.apiBaseUrl,
});
}
constructor(
private readonly config: {
credentialsProvider: GithubCredentialsProvider;
repoVisibility: RepoVisibilityOptions;
apiBaseUrl: string | undefined;
},
) {}
async publish({
values,
workspacePath,
logger,
}: PublisherOptions): Promise<PublisherResult> {
const { owner, name } = parseGitUrl(values.storePath);
const { token } = await this.config.credentialsProvider.getCredentials({
url: values.storePath,
});
if (!token) {
throw new Error(
`No token could be acquired for URL: ${values.storePath}`,
);
}
const client = new Octokit({
auth: token,
baseUrl: this.config.apiBaseUrl,
previews: ['nebula-preview'],
});
const description = values.description as string;
const access = values.access as string;
const remoteUrl = await this.createRemote({
client,
description,
access,
name,
owner,
});
await initRepoAndPush({
dir: path.join(workspacePath, 'result'),
remoteUrl,
auth: {
username: 'x-access-token',
password: token,
},
logger,
});
const catalogInfoUrl = remoteUrl.replace(
/\.git$/,
'/blob/master/catalog-info.yaml',
);
try {
await enableBranchProtectionOnDefaultRepoBranch({
owner,
client,
repoName: name,
logger,
});
} catch (e) {
throw new Error(`Failed to add branch protection to '${name}', ${e}`);
}
return { remoteUrl, catalogInfoUrl };
}
private async createRemote(opts: {
client: Octokit;
access: string;
name: string;
owner: string;
description: string;
}) {
const { client, access, description, owner, name } = opts;
const user = await client.users.getByUsername({
username: owner,
});
const repoCreationPromise =
user.data.type === 'Organization'
? client.repos.createInOrg({
name,
org: owner,
private: this.config.repoVisibility !== 'public',
visibility: this.config.repoVisibility,
description,
})
: client.repos.createForAuthenticatedUser({
name,
private: this.config.repoVisibility === 'private',
description,
});
const { data: newRepo } = await repoCreationPromise;
try {
if (access?.startsWith(`${owner}/`)) {
const [, team] = access.split('/');
await client.teams.addOrUpdateRepoPermissionsInOrg({
org: owner,
team_slug: team,
owner,
repo: name,
permission: 'admin',
});
// no need to add access if it's the person who owns the personal account
} else if (access && access !== owner) {
await client.repos.addCollaborator({
owner,
repo: name,
username: access,
permission: 'admin',
});
}
} catch (e) {
throw new Error(
`Failed to add access to '${access}'. Status ${e.status} ${e.message}`,
);
}
return newRepo.clone_url;
}
}
@@ -1,151 +0,0 @@
/*
* Copyright 2020 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('@gitbeaker/node', () => ({
Gitlab: jest.fn(),
}));
jest.mock('./helpers');
import os from 'os';
import path from 'path';
import { GitlabPublisher } from './gitlab';
import { Gitlab } from '@gitbeaker/node';
import { initRepoAndPush } from './helpers';
import { getVoidLogger } from '@backstage/backend-common';
describe('GitLab Publisher', () => {
const logger = getVoidLogger();
const mockGitlabClient = {
Namespaces: {
show: jest.fn(),
},
Projects: {
create: jest.fn(),
},
Users: {
current: jest.fn(),
},
};
beforeEach(() => {
jest.clearAllMocks();
((Gitlab as unknown) as jest.Mock).mockImplementation(
() => mockGitlabClient,
);
});
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const resultPath = path.resolve(workspacePath, 'result');
describe('publish: createRemoteInGitLab', () => {
it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => {
const publisher = await GitlabPublisher.fromConfig(
{
host: 'gitlab.com',
apiBaseUrl: 'https://gitlab.com/api/v4',
token: 'fake-token',
baseUrl: 'https://gitlab.hosted.com',
},
{ repoVisibility: 'public' },
);
mockGitlabClient.Namespaces.show.mockResolvedValue({
id: 42,
} as { id: number });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'mockclone',
} as { http_url_to_repo: string });
const result = await publisher!.publish({
values: {
isOrg: true,
storePath: 'https://gitlab.com/blam/test',
owner: 'bob',
},
workspacePath,
logger,
});
expect(Gitlab).toHaveBeenCalledWith({
token: 'fake-token',
host: 'https://gitlab.hosted.com',
});
expect(result).toEqual({
remoteUrl: 'mockclone',
catalogInfoUrl: 'mockclone',
});
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespace_id: 42,
name: 'test',
visibility: 'public',
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'mockclone',
auth: { username: 'oauth2', password: 'fake-token' },
logger,
});
});
it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => {
const publisher = await GitlabPublisher.fromConfig(
{
host: 'gitlab.com',
apiBaseUrl: 'https://gitlab.com/api/v4',
token: 'fake-token',
baseUrl: 'https://gitlab.com',
},
{ repoVisibility: 'public' },
);
mockGitlabClient.Namespaces.show.mockResolvedValue({});
mockGitlabClient.Users.current.mockResolvedValue({
id: 21,
} as { id: number });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'mockclone',
} as { http_url_to_repo: string });
const result = await publisher!.publish({
values: {
storePath: 'https://gitlab.com/blam/test',
owner: 'bob',
},
workspacePath,
logger,
});
expect(result).toEqual({
remoteUrl: 'mockclone',
catalogInfoUrl: 'mockclone',
});
expect(mockGitlabClient.Users.current).toHaveBeenCalled();
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespace_id: 21,
name: 'test',
visibility: 'public',
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: resultPath,
remoteUrl: 'mockclone',
auth: { username: 'oauth2', password: 'fake-token' },
logger,
});
});
});
});
@@ -1,106 +0,0 @@
/*
* Copyright 2020 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 { PublisherBase, PublisherOptions, PublisherResult } from './types';
import { Gitlab } from '@gitbeaker/node';
import { Gitlab as GitlabClient } from '@gitbeaker/core';
import { initRepoAndPush } from './helpers';
import parseGitUrl from 'git-url-parse';
import path from 'path';
import { GitLabIntegrationConfig } from '@backstage/integration';
export type RepoVisibilityOptions = 'private' | 'internal' | 'public';
export class GitlabPublisher implements PublisherBase {
static async fromConfig(
config: GitLabIntegrationConfig,
{ repoVisibility }: { repoVisibility: RepoVisibilityOptions },
) {
if (!config.token) {
return undefined;
}
const client = new Gitlab({ host: config.baseUrl, token: config.token });
return new GitlabPublisher({
token: config.token,
client,
repoVisibility,
});
}
constructor(
private readonly config: {
token: string;
client: GitlabClient;
repoVisibility: RepoVisibilityOptions;
},
) {}
async publish({
values,
workspacePath,
logger,
}: PublisherOptions): Promise<PublisherResult> {
const { owner, name } = parseGitUrl(values.storePath);
const remoteUrl = await this.createRemote({
owner,
name,
});
await initRepoAndPush({
dir: path.join(workspacePath, 'result'),
remoteUrl,
auth: {
username: 'oauth2',
password: this.config.token,
},
logger,
});
const catalogInfoUrl = remoteUrl.replace(
/\.git$/,
'/-/blob/master/catalog-info.yaml',
);
return { remoteUrl, catalogInfoUrl };
}
private async createRemote(opts: { name: string; owner: string }) {
const { owner, name } = opts;
// TODO(blam): this needs cleaning up to be nicer. The amount of brackets is too damn high!
// Shouldn't have to cast things now
let targetNamespace = ((await this.config.client.Namespaces.show(
owner,
)) as {
id: number;
}).id;
if (!targetNamespace) {
targetNamespace = ((await this.config.client.Users.current()) as {
id: number;
}).id;
}
const project = (await this.config.client.Projects.create({
namespace_id: targetNamespace,
name: name,
visibility: this.config.repoVisibility,
})) as { http_url_to_repo: string };
return project?.http_url_to_repo;
}
}
@@ -1,27 +0,0 @@
/*
* Copyright 2020 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 { AzurePublisher } from './azure';
export { BitbucketPublisher } from './bitbucket';
export { GithubPublisher } from './github';
export type { RepoVisibilityOptions } from './github';
export { GitlabPublisher } from './gitlab';
export { Publishers } from './publishers';
export type {
PublisherBase,
PublisherBuilder,
PublisherOptions,
PublisherResult,
} from './types';
@@ -1,127 +0,0 @@
/*
* Copyright 2020 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 { Publishers } from './publishers';
import { GithubPublisher } from './github';
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { AzurePublisher } from './azure';
import { GitlabPublisher } from './gitlab';
import { BitbucketPublisher } from './bitbucket';
jest.mock('@octokit/rest');
jest.mock('azure-devops-node-api');
describe('Publishers', () => {
const logger = getVoidLogger();
it('should throw an error when the publisher for the source location is not registered', () => {
const publishers = new Publishers();
expect(() => publishers.get('https://github.com/org/repo')).toThrow(
expect.objectContaining({
message:
'Unable to find a publisher for URL: https://github.com/org/repo. Please make sure to register this host under an integration in app-config',
}),
);
});
it('should return the correct preparer when the source matches for github', async () => {
const publishers = await Publishers.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'blob' }],
},
}),
{
logger,
},
);
expect(publishers.get('https://github.com/org/repo')).toBeInstanceOf(
GithubPublisher,
);
});
it('should return the correct preparer when the source matches for azure', async () => {
const publishers = await Publishers.fromConfig(
new ConfigReader({
integrations: {
azure: [{ host: 'dev.azure.com', token: 'blob' }],
},
}),
{
logger,
},
);
expect(
publishers.get('https://dev.azure.com/org/project/_git/repo'),
).toBeInstanceOf(AzurePublisher);
});
it('should return the correct preparer when the source matches for bitbucket', async () => {
const publishers = await Publishers.fromConfig(
new ConfigReader({
integrations: {
bitbucket: [
{ host: 'bitbucket.com', username: 'foo', token: 'blob' },
],
},
}),
{
logger,
},
);
expect(publishers.get('https://bitbucket.org/owner/repo')).toBeInstanceOf(
BitbucketPublisher,
);
});
it('should return the correct preparer when the source matches for gitlab', async () => {
const publishers = await Publishers.fromConfig(
new ConfigReader({
integrations: {
gitlab: [{ host: 'gitlab.com', token: 'blob' }],
},
}),
{
logger,
},
);
expect(publishers.get('https://gitlab.com/owner/repo')).toBeInstanceOf(
GitlabPublisher,
);
});
it('should respect registrations for custom URLs for providers using the integrations config', async () => {
const publishers = await Publishers.fromConfig(
new ConfigReader({
integrations: {
github: [
{ host: 'my.special.github.enterprise.thing', token: 'lolghe' },
],
},
}),
{
logger,
},
);
expect(
publishers.get('https://my.special.github.enterprise.thing/org/repo'),
).toBeInstanceOf(GithubPublisher);
});
});
@@ -1,113 +0,0 @@
/*
* Copyright 2020 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 { Config } from '@backstage/config';
import { PublisherBase, PublisherBuilder } from './types';
import {
GithubPublisher,
RepoVisibilityOptions as GithubRepoVisibilityOptions,
} from './github';
import {
GitlabPublisher,
RepoVisibilityOptions as GitlabRepoVisibilityOptions,
} from './gitlab';
import { AzurePublisher } from './azure';
import {
BitbucketPublisher,
RepoVisibilityOptions as BitbucketRepoVisibilityOptions,
} from './bitbucket';
import { Logger } from 'winston';
import { ScmIntegrations } from '@backstage/integration';
export class Publishers implements PublisherBuilder {
private publisherMap = new Map<string, PublisherBase | undefined>();
register(host: string, preparer: PublisherBase | undefined) {
this.publisherMap.set(host, preparer);
}
get(url: string): PublisherBase {
const preparer = this.publisherMap.get(new URL(url).host);
if (!preparer) {
throw new Error(
`Unable to find a publisher for URL: ${url}. Please make sure to register this host under an integration in app-config`,
);
}
return preparer;
}
static async fromConfig(
config: Config,
_options: { logger: Logger },
): Promise<PublisherBuilder> {
const publishers = new Publishers();
const scm = ScmIntegrations.fromConfig(config);
for (const integration of scm.azure.list()) {
const publisher = await AzurePublisher.fromConfig(integration.config);
if (publisher) {
publishers.register(integration.config.host, publisher);
}
}
for (const integration of scm.github.list()) {
const repoVisibility = (config.getOptionalString(
'scaffolder.github.visibility',
) ?? 'public') as GithubRepoVisibilityOptions;
const publisher = await GithubPublisher.fromConfig(integration.config, {
repoVisibility,
});
if (publisher) {
publishers.register(integration.config.host, publisher);
}
}
for (const integration of scm.gitlab.list()) {
const repoVisibility = (config.getOptionalString(
'scaffolder.gitlab.visibility',
) ?? 'public') as GitlabRepoVisibilityOptions;
const publisher = await GitlabPublisher.fromConfig(integration.config, {
repoVisibility,
});
if (publisher) {
publishers.register(integration.config.host, publisher);
}
}
for (const integration of scm.bitbucket.list()) {
const repoVisibility = (config.getOptionalString(
'scaffolder.bitbucket.visibility',
) ?? 'public') as BitbucketRepoVisibilityOptions;
const publisher = await BitbucketPublisher.fromConfig(
integration.config,
{
repoVisibility,
},
);
if (publisher) {
publishers.register(integration.config.host, publisher);
}
}
return publishers;
}
}
@@ -1,47 +0,0 @@
/*
* Copyright 2020 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 { TemplaterValues } from '../templater';
import { Logger } from 'winston';
/**
* Publisher is in charge of taking a folder created by
* the templater, and pushing it to a remote storage
*/
export type PublisherBase = {
/**
*
* @param opts object containing the template entity from the service
* catalog, plus the values from the form and the directory that has
* been templated
*/
publish(opts: PublisherOptions): Promise<PublisherResult>;
};
export type PublisherOptions = {
values: TemplaterValues;
workspacePath: string;
logger: Logger;
};
export type PublisherResult = {
remoteUrl: string;
catalogInfoUrl?: string;
};
export type PublisherBuilder = {
register(host: string, publisher: PublisherBase): void;
get(storePath: string): PublisherBase;
};
@@ -1,279 +0,0 @@
/*
* Copyright 2020 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.
*/
const runCommand = jest.fn();
const commandExists = jest.fn();
jest.mock('./helpers', () => ({ runCommand }));
jest.mock('command-exists', () => commandExists);
jest.mock('fs-extra');
import { ContainerRunner } from '@backstage/backend-common';
import fs from 'fs-extra';
import parseGitUrl from 'git-url-parse';
import path from 'path';
import { PassThrough } from 'stream';
import { CookieCutter } from './cookiecutter';
describe('CookieCutter Templater', () => {
const containerRunner: jest.Mocked<ContainerRunner> = {
runContainer: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
commandExists.mockRejectedValue(null);
});
it('should write a cookiecutter.json file with the values from the entity', async () => {
const values = {
owner: 'blobby',
storePath: 'https://github.com/org/repo',
description: 'description',
component_id: 'newthing',
destination: {
git: parseGitUrl('https://github.com/org/repo'),
},
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
const templater = new CookieCutter({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
});
expect(fs.ensureDir).toBeCalledWith(path.join('tempdir', 'intermediate'));
expect(fs.writeJson).toBeCalledWith(
path.join('tempdir', 'template', 'cookiecutter.json'),
expect.objectContaining(values),
);
});
it('should merge any value that is in the cookiecutter.json path already', async () => {
const existingJson = {
_copy_without_render: ['./github/workflows/*'],
};
jest
.spyOn(fs, 'readJSON')
.mockImplementationOnce(() => Promise.resolve(existingJson));
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
const values = {
owner: 'blobby',
storePath: 'https://github.com/org/repo',
component_id: 'something',
destination: {
git: parseGitUrl('https://github.com/org/repo'),
},
};
const templater = new CookieCutter({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
});
expect(fs.writeJSON).toBeCalledWith(
path.join('tempdir', 'template', 'cookiecutter.json'),
{
...existingJson,
...values,
destination: {
git: expect.objectContaining({ organization: 'org', name: 'repo' }),
},
},
);
});
it('should throw an error if the cookiecutter json is malformed and not missing', async () => {
jest.spyOn(fs, 'readJSON').mockImplementationOnce(() => {
throw new Error('BAM');
});
const values = {
owner: 'blobby',
storePath: 'https://github.com/org/repo',
destination: {
git: parseGitUrl('https://github.com/org/repo'),
},
};
const templater = new CookieCutter({ containerRunner });
await expect(
templater.run({
workspacePath: 'tempdir',
values,
}),
).rejects.toThrow('BAM');
});
it('should run the correct docker container with the correct bindings for the volumes', async () => {
const values = {
owner: 'blobby',
storePath: 'https://github.com/org/repo',
component_id: 'newthing',
destination: {
git: parseGitUrl('https://github.com/org/repo'),
},
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
jest
.spyOn(fs, 'realpath')
.mockImplementation(x => Promise.resolve(x.toString()));
const templater = new CookieCutter({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
});
expect(containerRunner.runContainer).toHaveBeenCalledWith({
imageName: 'spotify/backstage-cookiecutter',
command: 'cookiecutter',
args: ['--no-input', '-o', '/output', '/input', '--verbose'],
envVars: { HOME: '/tmp' },
mountDirs: {
[path.join('tempdir', 'template')]: '/input',
[path.join('tempdir', 'intermediate')]: '/output',
},
workingDir: '/input',
logStream: undefined,
});
});
it('should run the docker container mentioned in configs, overriding the default', async () => {
const values = {
owner: 'blobby',
storePath: 'https://github.com/org/repo',
imageName: 'foo/cookiecutter-image-with-extensions',
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
const templater = new CookieCutter({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
});
expect(containerRunner.runContainer).toHaveBeenCalledWith(
expect.objectContaining({
imageName: 'foo/cookiecutter-image-with-extensions',
}),
);
});
it('should pass through the streamer to the run docker helper', async () => {
const stream = new PassThrough();
const values = {
owner: 'blobby',
storePath: 'https://github.com/org/repo',
component_id: 'newthing',
destination: {
git: parseGitUrl('https://github.com/org/repo'),
},
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
const templater = new CookieCutter({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
logStream: stream,
});
expect(containerRunner.runContainer).toHaveBeenCalledWith({
imageName: 'spotify/backstage-cookiecutter',
command: 'cookiecutter',
args: ['--no-input', '-o', '/output', '/input', '--verbose'],
envVars: { HOME: '/tmp' },
mountDirs: {
[path.join('tempdir', 'template')]: '/input',
[path.join('tempdir', 'intermediate')]: '/output',
},
workingDir: '/input',
logStream: stream,
});
});
describe('when cookiecutter is available', () => {
it('use the binary', async () => {
const stream = new PassThrough();
const values = {
owner: 'blobby',
storePath: 'https://github.com/org/repo',
component_id: 'newthing',
destination: {
git: parseGitUrl('https://github.com/org/repo'),
},
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
commandExists.mockResolvedValueOnce(true);
const templater = new CookieCutter({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
logStream: stream,
});
expect(runCommand).toHaveBeenCalledWith({
command: 'cookiecutter',
args: expect.arrayContaining([
'--no-input',
'-o',
path.join('tempdir', 'intermediate'),
path.join('tempdir', 'template'),
'--verbose',
]),
logStream: stream,
});
});
});
describe('when nothing was generated', () => {
it('throws an error', async () => {
const stream = new PassThrough();
jest
.spyOn(fs, 'readdir')
.mockImplementationOnce(() => Promise.resolve([]));
const templater = new CookieCutter({ containerRunner });
await expect(
templater.run({
workspacePath: 'tempdir',
values: {
owner: 'blobby',
storePath: 'https://github.com/org/repo',
destination: {
git: parseGitUrl('https://github.com/org/repo'),
},
},
logStream: stream,
}),
).rejects.toThrow(/No data generated by cookiecutter/);
});
});
});
@@ -1,107 +0,0 @@
/*
* Copyright 2020 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 { ContainerRunner } from '@backstage/backend-common';
import { JsonValue } from '@backstage/config';
import commandExists from 'command-exists';
import fs from 'fs-extra';
import path from 'path';
import { runCommand } from './helpers';
import { TemplaterBase, TemplaterRunOptions } from './types';
export class CookieCutter implements TemplaterBase {
private readonly containerRunner: ContainerRunner;
constructor({ containerRunner }: { containerRunner: ContainerRunner }) {
this.containerRunner = containerRunner;
}
private async fetchTemplateCookieCutter(
directory: string,
): Promise<Record<string, JsonValue>> {
try {
return await fs.readJSON(path.join(directory, 'cookiecutter.json'));
} catch (ex) {
if (ex.code !== 'ENOENT') {
throw ex;
}
return {};
}
}
public async run({
workspacePath,
values,
logStream,
}: TemplaterRunOptions): Promise<void> {
const templateDir = path.join(workspacePath, 'template');
const intermediateDir = path.join(workspacePath, 'intermediate');
await fs.ensureDir(intermediateDir);
const resultDir = path.join(workspacePath, 'result');
// First lets grab the default cookiecutter.json file
const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir);
const { imageName, ...valuesForCookieCutterJson } = values;
const cookieInfo = {
...cookieCutterJson,
...valuesForCookieCutterJson,
};
await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo);
// Directories to bind on container
const mountDirs = {
[templateDir]: '/input',
[intermediateDir]: '/output',
};
// the command-exists package returns `true` or throws an error
const cookieCutterInstalled = await commandExists('cookiecutter').catch(
() => false,
);
if (cookieCutterInstalled) {
await runCommand({
command: 'cookiecutter',
args: ['--no-input', '-o', intermediateDir, templateDir, '--verbose'],
logStream,
});
} else {
await this.containerRunner.runContainer({
imageName: imageName || 'spotify/backstage-cookiecutter',
command: 'cookiecutter',
args: ['--no-input', '-o', '/output', '/input', '--verbose'],
mountDirs,
workingDir: '/input',
// Set the home directory inside the container as something that applications can
// write to, otherwise they will just fail trying to write to /
envVars: { HOME: '/tmp' },
logStream,
});
}
// if cookiecutter was successful, intermediateDir will contain
// exactly one directory.
const [generated] = await fs.readdir(intermediateDir);
if (generated === undefined) {
throw new Error('No data generated by cookiecutter');
}
await fs.move(path.join(intermediateDir, generated), resultDir);
}
}
@@ -1,159 +0,0 @@
/*
* Copyright 2020 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 { ContainerRunner } from '@backstage/backend-common';
import fs from 'fs-extra';
import path from 'path';
import * as yaml from 'yaml';
import { TemplaterBase, TemplaterRunOptions } from '../types';
// TODO(blam): Replace with the universal import from github-actions after a release
// As it will break the E2E without it
const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug';
export class CreateReactAppTemplater implements TemplaterBase {
private readonly containerRunner: ContainerRunner;
constructor({ containerRunner }: { containerRunner: ContainerRunner }) {
this.containerRunner = containerRunner;
}
public async run({
workspacePath,
values,
logStream,
}: TemplaterRunOptions): Promise<void> {
const {
component_id: componentName,
use_typescript: withTypescript,
use_github_actions: withGithubActions,
description,
owner,
} = values;
const intermediateDir = path.join(workspacePath, 'template');
await fs.ensureDir(intermediateDir);
const mountDirs = {
[intermediateDir]: '/template',
[intermediateDir]: '/result',
};
await this.containerRunner.runContainer({
imageName: 'node:lts-alpine',
command: ['npx'],
args: [
'create-react-app',
componentName as string,
withTypescript ? ' --template typescript' : '',
],
mountDirs,
workingDir: '/result',
logStream: logStream,
// Set the home directory inside the container as something that applications can
// write to, otherwise they will just fail trying to write to /
envVars: { HOME: '/tmp' },
});
// if cookiecutter was successful, intermediateDir will contain
// exactly one directory.
const [generated] = await fs.readdir(intermediateDir);
if (generated === undefined) {
throw new Error('No data generated by cookiecutter');
}
const resultDir = path.join(workspacePath, 'result');
await fs.move(path.join(intermediateDir, generated), resultDir);
const extraAnnotations: Record<string, string> = {};
if (withGithubActions) {
await fs.mkdir(`${resultDir}/.github`);
await fs.mkdir(`${resultDir}/.github/workflows`);
const githubActionsYaml = `
name: CRA Build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
steps:
- name: checkout code
uses: actions/checkout@v1
- name: get yarn cache
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
with:
path: \${{ steps.yarn-cache.outputs.dir }}
key: \${{ runner.os }}-yarn-\${{ hashFiles('**/yarn.lock') }}
restore-keys: |
\${{ runner.os }}-yarn-
- name: use node.js \${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: \${{ matrix.node-version }}
- name: yarn install, build, and test
working-directory: .
run: |
yarn install
yarn build
yarn test
env:
CI: true
`;
await fs.writeFile(
`${resultDir}/.github/workflows/main.yml`,
githubActionsYaml,
);
extraAnnotations[
GITHUB_ACTIONS_ANNOTATION
] = `${values?.destination?.git?.owner}/${values?.destination?.git?.name}`;
}
const componentInfo = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: componentName,
description,
annotations: {
...extraAnnotations,
},
},
spec: {
type: 'website',
lifecycle: 'experimental',
owner,
},
};
await fs.writeFile(
`${resultDir}/catalog-info.yaml`,
yaml.stringify(componentInfo),
);
}
}
@@ -1,75 +0,0 @@
/*
* Copyright 2020 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 { InputError } from '@backstage/errors';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { spawn } from 'child_process';
import { PassThrough, Writable } from 'stream';
export type RunCommandOptions = {
command: string;
args: string[];
logStream?: Writable;
};
/**
* Gets the templater key to use for templating from the entity
* @param entity Template entity
*/
export const getTemplaterKey = (entity: TemplateEntityV1alpha1): string => {
const { templater } = entity.spec;
if (!templater) {
throw new InputError('Template does not have a required templating key');
}
return templater;
};
/**
*
* @param options the options object
* @param options.command the command to run
* @param options.args the arguments to pass the command
* @param options.logStream the log streamer to capture log messages
*/
export const runCommand = async ({
command,
args,
logStream = new PassThrough(),
}: RunCommandOptions) => {
await new Promise<void>((resolve, reject) => {
const process = spawn(command, args);
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(`Command ${command} failed, exit code: ${code}`);
}
return resolve();
});
});
};
@@ -1,20 +0,0 @@
/*
* Copyright 2020 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 * from './cookiecutter';
export * from './types';
export * from './helpers';
export * from './templaters';
export * from './cra';
@@ -1,43 +0,0 @@
/*
* Copyright 2020 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 { ContainerRunner } from '@backstage/backend-common';
import { CookieCutter } from './cookiecutter';
import { Templaters } from './templaters';
describe('Templaters', () => {
const containerRunner: jest.Mocked<ContainerRunner> = {
runContainer: jest.fn(),
};
it('should throw an error when the templater is not registered', () => {
const templaters = new Templaters();
expect(() => templaters.get('cookiecutter')).toThrow(
expect.objectContaining({
message: 'No templater registered for template: "cookiecutter"',
}),
);
});
it('should return the correct templater when the templater matches', () => {
const templaters = new Templaters();
const templater = new CookieCutter({ containerRunner });
templaters.register('cookiecutter', templater);
expect(templaters.get('cookiecutter')).toBe(templater);
});
});
@@ -1,39 +0,0 @@
/*
* Copyright 2020 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 {
TemplaterBase,
SupportedTemplatingKey,
TemplaterBuilder,
} from './types';
export class Templaters implements TemplaterBuilder {
private templaterMap = new Map<SupportedTemplatingKey, TemplaterBase>();
register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase) {
this.templaterMap.set(templaterKey, templater);
}
get(templaterId: string): TemplaterBase {
const templater = this.templaterMap.get(templaterId);
if (!templater) {
throw new Error(`No templater registered for template: "${templaterId}"`);
}
return templater;
}
}
@@ -1,73 +0,0 @@
/*
* Copyright 2020 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 gitUrlParse from 'git-url-parse';
import type { Writable } from 'stream';
/**
* Currently the required template values. The owner
* and where to store the result from templating
*/
export type RequiredTemplateValues = {
owner: string;
storePath: string;
destination?: {
git?: gitUrlParse.GitUrl;
};
};
export type TemplaterValues = RequiredTemplateValues & Record<string, any>;
/**
* The returned directory from the templater which is ready
* to pass to the next stage of the scaffolder which is publishing
*/
export type TemplaterRunResult = {
resultDir: string;
};
/**
* The values that the templater will receive. The directory of the
* skeleton, with the values from the frontend. A dedicated log stream and a docker
* client to run any templater on top of your directory.
*/
export type TemplaterRunOptions = {
workspacePath: string;
values: TemplaterValues;
logStream?: Writable;
};
export type TemplaterBase = {
// runs the templating with the values and returns the directory to push the VCS
run(opts: TemplaterRunOptions): Promise<void>;
};
export type TemplaterConfig = {
templater?: TemplaterBase;
};
/**
* List of supported templating options
*/
export type SupportedTemplatingKey = 'cookiecutter' | string;
/**
* The templater builder holds the templaters ready for run time
*/
export type TemplaterBuilder = {
register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void;
get(templater: string): TemplaterBase;
};
@@ -1,101 +0,0 @@
/*
* 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 { resolve as resolvePath, dirname } from 'path';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import parseGitUrl from 'git-url-parse';
import { TaskSpec } from './types';
import {
getTemplaterKey,
joinGitUrlPath,
parseLocationAnnotation,
TemplaterValues,
} from '../stages';
export function templateEntityToSpec(
template: TemplateEntityV1alpha1,
inputValues: TemplaterValues,
): TaskSpec {
const steps: TaskSpec['steps'] = [];
const { protocol, location } = parseLocationAnnotation(template);
let url: string;
if (protocol === 'file') {
const path = resolvePath(dirname(location), template.spec.path || '.');
url = `file://${path}`;
} else {
url = joinGitUrlPath(location, template.spec.path);
}
const templater = getTemplaterKey(template);
const values = {
...inputValues,
destination: {
git: parseGitUrl(inputValues.storePath),
},
} as TemplaterValues;
steps.push({
id: 'prepare',
name: 'Prepare',
action: 'legacy:prepare',
input: {
protocol,
url,
},
});
steps.push({
id: 'template',
name: 'Template',
action: 'legacy:template',
input: {
templater,
values,
},
});
steps.push({
id: 'publish',
name: 'Publish',
action: 'legacy:publish',
input: {
values,
},
});
steps.push({
id: 'register',
name: 'Register',
action: 'catalog:register',
input: {
catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}',
},
});
return {
baseUrl: undefined, // not used by legacy actions
values: {},
steps,
output: {
remoteUrl: '{{ steps.publish.output.remoteUrl }}',
catalogInfoUrl: '{{ steps.register.output.catalogInfoUrl }}',
entityRef: '{{ steps.register.output.entityRef }}',
},
};
}
@@ -18,12 +18,6 @@ import { Config } from '@backstage/config';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
PreparerBuilder,
TemplaterBuilder,
TemplaterValues,
PublisherBuilder,
} from '../scaffolder';
import { CatalogEntityClient } from '../lib/catalog';
import { validate } from 'jsonschema';
import {
@@ -31,27 +25,21 @@ import {
StorageTaskBroker,
TaskWorker,
} from '../scaffolder/tasks';
import { templateEntityToSpec } from '../scaffolder/tasks/TemplateConverter';
import { TemplateActionRegistry } from '../scaffolder/actions/TemplateActionRegistry';
import { createLegacyActions } from '../scaffolder/stages/legacy';
import { getEntityBaseUrl, getWorkingDirectory } from './helpers';
import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common';
import {
ContainerRunner,
PluginDatabaseManager,
UrlReader,
} from '@backstage/backend-common';
import { InputError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import {
TemplateEntityV1alpha1,
TemplateEntityV1beta2,
Entity,
} from '@backstage/catalog-model';
import { TemplateEntityV1beta2, Entity } from '@backstage/catalog-model';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateAction } from '../scaffolder/actions';
import { createBuiltinActions } from '../scaffolder/actions/builtin/createBuiltinActions';
export interface RouterOptions {
preparers: PreparerBuilder;
templaters: TemplaterBuilder;
publishers: PublisherBuilder;
logger: Logger;
config: Config;
reader: UrlReader;
@@ -59,19 +47,11 @@ export interface RouterOptions {
catalogClient: CatalogApi;
actions?: TemplateAction<any>[];
taskWorkers?: number;
}
function isAlpha1Template(
entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2,
): entity is TemplateEntityV1alpha1 {
return (
entity.apiVersion === 'backstage.io/v1alpha1' ||
entity.apiVersion === 'backstage.io/v1beta1'
);
containerRunner: ContainerRunner;
}
function isBeta2Template(
entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2,
entity: TemplateEntityV1beta2,
): entity is TemplateEntityV1beta2 {
return entity.apiVersion === 'backstage.io/v1beta2';
}
@@ -83,15 +63,13 @@ export async function createRouter(
router.use(express.json());
const {
preparers,
templaters,
publishers,
logger: parentLogger,
config,
reader,
database,
catalogClient,
actions,
containerRunner,
taskWorkers,
} = options;
@@ -118,20 +96,13 @@ export async function createRouter(
const actionsToRegister = Array.isArray(actions)
? actions
: [
...createLegacyActions({
preparers,
publishers,
templaters,
}),
...createBuiltinActions({
integrations,
catalogClient,
templaters,
reader,
config,
}),
];
: createBuiltinActions({
integrations,
catalogClient,
containerRunner,
reader,
config,
});
actionsToRegister.forEach(action => actionRegistry.register(action));
workers.forEach(worker => worker.start());
@@ -165,42 +136,6 @@ export async function createRouter(
schema,
})),
});
} else if (isAlpha1Template(template)) {
res.json({
title: template.metadata.title ?? template.metadata.name,
steps: [
{
title: 'Fill in template parameters',
schema: template.spec.schema,
},
{
title: 'Choose owner and repo',
schema: {
type: 'object',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description:
'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo',
},
access: {
type: 'string',
title: 'Access',
description:
'Who should have access, in org/team or user format',
},
},
},
},
],
});
} else {
throw new InputError(
`Unsupported apiVersion field in schema entity, ${
@@ -222,26 +157,14 @@ export async function createRouter(
})
.post('/v2/tasks', async (req, res) => {
const templateName: string = req.body.templateName;
const values: TemplaterValues = req.body.values;
const values = req.body.values;
const token = getBearerToken(req.headers.authorization);
const template = await entityClient.findTemplate(templateName, {
token,
});
let taskSpec;
if (isAlpha1Template(template)) {
logger.warn(
`[DEPRECATION] - Template: ${template.metadata.name} has version ${template.apiVersion} which is going to be deprecated. Please refer to https://backstage.io/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2 for help on migrating`,
);
const result = validate(values, template.spec.schema);
if (!result.valid) {
res.status(400).json({ errors: result.errors });
return;
}
taskSpec = templateEntityToSpec(template, values);
} else if (isBeta2Template(template)) {
if (isBeta2Template(template)) {
for (const parameters of [template.spec.parameters ?? []].flat()) {
const result = validate(values, parameters);